@akinon/projectzero 1.65.0 → 1.67.0

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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # @akinon/projectzero
2
2
 
3
+ ## 1.67.0
4
+
5
+ ## 1.66.0
6
+
3
7
  ## 1.65.0
4
8
 
5
9
  ## 1.64.0
@@ -1,5 +1,68 @@
1
1
  # projectzeronext
2
2
 
3
+ ## 1.67.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a1a99f0: ZERO-2887: Add LoaderSpinner to Filters component
8
+ - bc2b411: ZERO-2825: Add attribute-based shipping options to checkout page
9
+ - 3d2212a: ZERO-2745: Add multi basket support
10
+ - 38a634e: ZERO-2893: Refactor category filter handling and URL parameters
11
+ - 9e25a64: ZERO-2835: Update category page layout with breadcrumb
12
+
13
+ ### Patch Changes
14
+
15
+ - Updated dependencies [bc2b411]
16
+ - Updated dependencies [3d2212a]
17
+ - Updated dependencies [8f47cca]
18
+ - Updated dependencies [9e25a64]
19
+ - @akinon/next@1.67.0
20
+ - @akinon/pz-akifast@1.67.0
21
+ - @akinon/pz-b2b@1.67.0
22
+ - @akinon/pz-basket-gift-pack@1.67.0
23
+ - @akinon/pz-bkm@1.67.0
24
+ - @akinon/pz-checkout-gift-pack@1.67.0
25
+ - @akinon/pz-click-collect@1.67.0
26
+ - @akinon/pz-credit-payment@1.67.0
27
+ - @akinon/pz-gpay@1.67.0
28
+ - @akinon/pz-masterpass@1.67.0
29
+ - @akinon/pz-one-click-checkout@1.67.0
30
+ - @akinon/pz-otp@1.67.0
31
+ - @akinon/pz-pay-on-delivery@1.67.0
32
+ - @akinon/pz-saved-card@1.67.0
33
+ - @akinon/pz-tabby-extension@1.67.0
34
+
35
+ ## 1.66.0
36
+
37
+ ### Minor Changes
38
+
39
+ - 572d2e8: ZERO-2667: Add iframe support for redirection payment methods
40
+ - 2e6104d: ZERO-2888:Edit the numbering in the pagination and the visibility of the prev and next buttons
41
+
42
+ ### Patch Changes
43
+
44
+ - Updated dependencies [572d2e8]
45
+ - Updated dependencies [2e6104d]
46
+ - Updated dependencies [7b05522]
47
+ - Updated dependencies [29ead87]
48
+ - Updated dependencies [12a873e]
49
+ - Updated dependencies [f2c325c]
50
+ - @akinon/next@1.66.0
51
+ - @akinon/pz-otp@1.66.0
52
+ - @akinon/pz-akifast@1.66.0
53
+ - @akinon/pz-b2b@1.66.0
54
+ - @akinon/pz-basket-gift-pack@1.66.0
55
+ - @akinon/pz-bkm@1.66.0
56
+ - @akinon/pz-checkout-gift-pack@1.66.0
57
+ - @akinon/pz-click-collect@1.66.0
58
+ - @akinon/pz-credit-payment@1.66.0
59
+ - @akinon/pz-gpay@1.66.0
60
+ - @akinon/pz-masterpass@1.66.0
61
+ - @akinon/pz-one-click-checkout@1.66.0
62
+ - @akinon/pz-pay-on-delivery@1.66.0
63
+ - @akinon/pz-saved-card@1.66.0
64
+ - @akinon/pz-tabby-extension@1.66.0
65
+
3
66
  ## 1.65.0
4
67
 
5
68
  ### Minor Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectzeronext",
3
- "version": "1.65.0",
3
+ "version": "1.67.0",
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "scripts": {
@@ -22,21 +22,21 @@
22
22
  "prestart": "pz-prestart"
23
23
  },
24
24
  "dependencies": {
25
- "@akinon/next": "1.65.0",
26
- "@akinon/pz-akifast": "1.65.0",
27
- "@akinon/pz-b2b": "1.65.0",
28
- "@akinon/pz-basket-gift-pack": "1.65.0",
29
- "@akinon/pz-bkm": "1.65.0",
30
- "@akinon/pz-checkout-gift-pack": "1.65.0",
31
- "@akinon/pz-click-collect": "1.65.0",
32
- "@akinon/pz-credit-payment": "1.65.0",
33
- "@akinon/pz-gpay": "1.65.0",
34
- "@akinon/pz-masterpass": "1.65.0",
35
- "@akinon/pz-one-click-checkout": "1.65.0",
36
- "@akinon/pz-otp": "1.65.0",
37
- "@akinon/pz-pay-on-delivery": "1.65.0",
38
- "@akinon/pz-saved-card": "1.65.0",
39
- "@akinon/pz-tabby-extension": "1.65.0",
25
+ "@akinon/next": "1.67.0",
26
+ "@akinon/pz-akifast": "1.67.0",
27
+ "@akinon/pz-b2b": "1.67.0",
28
+ "@akinon/pz-basket-gift-pack": "1.67.0",
29
+ "@akinon/pz-bkm": "1.67.0",
30
+ "@akinon/pz-checkout-gift-pack": "1.67.0",
31
+ "@akinon/pz-click-collect": "1.67.0",
32
+ "@akinon/pz-credit-payment": "1.67.0",
33
+ "@akinon/pz-gpay": "1.67.0",
34
+ "@akinon/pz-masterpass": "1.67.0",
35
+ "@akinon/pz-one-click-checkout": "1.67.0",
36
+ "@akinon/pz-otp": "1.67.0",
37
+ "@akinon/pz-pay-on-delivery": "1.67.0",
38
+ "@akinon/pz-saved-card": "1.67.0",
39
+ "@akinon/pz-tabby-extension": "1.67.0",
40
40
  "@hookform/resolvers": "2.9.0",
41
41
  "@next/third-parties": "14.1.0",
42
42
  "@react-google-maps/api": "2.17.1",
@@ -60,7 +60,7 @@
60
60
  "yup": "0.32.11"
61
61
  },
62
62
  "devDependencies": {
63
- "@akinon/eslint-plugin-projectzero": "1.65.0",
63
+ "@akinon/eslint-plugin-projectzero": "1.67.0",
64
64
  "@semantic-release/changelog": "6.0.2",
65
65
  "@semantic-release/exec": "6.0.3",
66
66
  "@semantic-release/git": "10.0.1",
@@ -4,11 +4,14 @@ import { PageProps } from '@akinon/next/types';
4
4
  import CategoryLayout from '@theme/views/category/layout';
5
5
 
6
6
  async function Page({ params, searchParams }: PageProps<{ pk: number }>) {
7
- const { data } = await getCategoryData({ pk: params.pk, searchParams });
7
+ const { data, breadcrumbData } = await getCategoryData({
8
+ pk: params.pk,
9
+ searchParams
10
+ });
8
11
 
9
12
  return (
10
13
  <>
11
- <CategoryLayout data={data} />
14
+ <CategoryLayout data={data} breadcrumbData={breadcrumbData} />
12
15
  </>
13
16
  );
14
17
  }
@@ -1,8 +1,8 @@
1
- import { MouseEvent, useCallback, useEffect, useState } from 'react';
1
+ import { useCallback, useEffect, useState } from 'react';
2
2
  import { PaginationProps } from '@theme/components/types';
3
3
  import { twMerge } from 'tailwind-merge';
4
4
  import clsx from 'clsx';
5
- import { Link, Button } from '@theme/components';
5
+ import { Button } from '@theme/components';
6
6
  import usePagination from '@akinon/next/hooks/use-pagination';
7
7
  import { useLocalization } from '@akinon/next/hooks';
8
8
  import { useRouter } from '@akinon/next/hooks';
@@ -92,9 +92,7 @@ export const Pagination = (props: PaginationProps) => {
92
92
  }
93
93
  }, [numberOfPages, page, pageList, threshold]);
94
94
 
95
- const handleClick = (e: MouseEvent<HTMLAnchorElement>, url: string) => {
96
- e.preventDefault();
97
-
95
+ const handleClick = (url: string) => {
98
96
  const newUrl = new URL(url, window.location.origin);
99
97
  const page = newUrl.searchParams.get('page');
100
98
 
@@ -129,7 +127,7 @@ export const Pagination = (props: PaginationProps) => {
129
127
  if (type === 'list') {
130
128
  createListItems();
131
129
  }
132
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
130
+ }, [createListItems, type]);
133
131
 
134
132
  useEffect(() => {
135
133
  if (total && total !== paginationTotal) {
@@ -200,9 +198,8 @@ export const Pagination = (props: PaginationProps) => {
200
198
  >
201
199
  {prev && currentPage !== 1 && (
202
200
  <li>
203
- <Link
204
- onClick={(e) => handleClick(e, prev)}
205
- href={prev}
201
+ <button
202
+ onClick={() => handleClick(prev)}
206
203
  className={twMerge(
207
204
  'flex cursor-pointer text-sm px-2',
208
205
  prevClassName
@@ -212,16 +209,15 @@ export const Pagination = (props: PaginationProps) => {
212
209
  <span className="hidden lg:inline-block ms-4">
213
210
  {t('category.pagination.previous')}
214
211
  </span>
215
- </Link>
212
+ </button>
216
213
  </li>
217
214
  )}
218
215
 
219
216
  {paginationItems.map((item, i) => (
220
217
  <li key={i}>
221
218
  {item?.url != '#' ? (
222
- <Link
223
- onClick={(e) => handleClick(e, item.url)}
224
- href={item.url}
219
+ <button
220
+ onClick={() => handleClick(item.url)}
225
221
  className={twMerge(
226
222
  clsx(
227
223
  'text-xs px-2 cursor-pointer',
@@ -234,7 +230,7 @@ export const Pagination = (props: PaginationProps) => {
234
230
  )}
235
231
  >
236
232
  {item?.page}
237
- </Link>
233
+ </button>
238
234
  ) : (
239
235
  <span className="cursor-default text-xs flex items-center justify-center">
240
236
  {item?.page}
@@ -245,9 +241,8 @@ export const Pagination = (props: PaginationProps) => {
245
241
 
246
242
  {showNext && (
247
243
  <li>
248
- <Link
249
- onClick={(e) => handleClick(e, next)}
250
- href={next}
244
+ <button
245
+ onClick={() => handleClick(next)}
251
246
  className={twMerge(
252
247
  'flex cursor-pointer text-xs px-2',
253
248
  nextClassName
@@ -257,7 +252,7 @@ export const Pagination = (props: PaginationProps) => {
257
252
  {t('category.pagination.next')}
258
253
  </span>
259
254
  <span>&gt;</span>
260
- </Link>
255
+ </button>
261
256
  </li>
262
257
  )}
263
258
  </ul>
@@ -5,6 +5,7 @@ import { Icon, Link } from '@theme/components';
5
5
  import { ROUTES } from '@theme/routes';
6
6
  import { useLocalization } from '@akinon/next/hooks';
7
7
  import { BreadcrumbResultType } from '@akinon/next/types';
8
+ import { capitalize } from '@akinon/next/utils';
8
9
 
9
10
  export interface BreadcrumbProps {
10
11
  breadcrumbList?: BreadcrumbResultType[];
@@ -26,7 +27,9 @@ export default function Breadcrumb(props: BreadcrumbProps) {
26
27
  <div className="flex items-center gap-3 text-xs leading-4 text-gray-950">
27
28
  {list.map((item, index) => (
28
29
  <Fragment key={index}>
29
- <Link href={item.url}>{item.text}</Link>
30
+ <Link href={item.url}>
31
+ {capitalize(item.text.toLocaleLowerCase())}
32
+ </Link>
30
33
  {index !== list.length - 1 && <Icon name="chevron-end" size={8} />}
31
34
  </Fragment>
32
35
  ))}
@@ -22,17 +22,28 @@ const CategoryActiveFilters = () => {
22
22
  const handleRemoveFilter = ({ facet, choice }) => {
23
23
  if (facet.widget_type === WIDGET_TYPE.category) {
24
24
  dispatch(removeCategoryFacet({ facet, choice }));
25
- return;
25
+ } else {
26
+ dispatch(toggleFacet({ facet, choice }));
26
27
  }
27
28
 
28
- dispatch(toggleFacet({ facet, choice }));
29
+ const urlSearchParams = new URLSearchParams(window.location.search);
30
+ urlSearchParams.delete(facet.search_key);
31
+ router.replace(pathname + '?' + urlSearchParams.toString());
29
32
  };
30
33
 
31
34
  const url = useMemo(() => {
32
- const facetSearchParams =
33
- convertFacetSearchParams(selectedFacets).toString();
35
+ const facetSearchParams = convertFacetSearchParams(selectedFacets);
36
+ const urlSearchParams = new URLSearchParams(searchParams.toString());
34
37
 
35
- const urlSearchParams = new URLSearchParams(facetSearchParams);
38
+ for (const key of Array.from(urlSearchParams.keys())) {
39
+ if (facetSearchParams.has(key)) {
40
+ urlSearchParams.delete(key);
41
+ }
42
+ }
43
+
44
+ for (const [key, value] of Array.from(facetSearchParams.entries())) {
45
+ urlSearchParams.append(key, value);
46
+ }
36
47
 
37
48
  const searchText = searchParams.get('search_text');
38
49
  const page = searchParams.get('page');
@@ -53,7 +64,6 @@ const CategoryActiveFilters = () => {
53
64
 
54
65
  useEffect(() => {
55
66
  router.push(url);
56
-
57
67
  // eslint-disable-next-line react-hooks/exhaustive-deps
58
68
  }, [url]);
59
69
 
@@ -1,12 +1,14 @@
1
1
  import clsx from 'clsx';
2
2
  import { useAppDispatch } from '@akinon/next/redux/hooks';
3
3
  import { Facet, FacetChoice } from '@akinon/next/types';
4
- import { Accordion, Radio, Checkbox } from '../../../components';
4
+ import { Accordion, Radio, Checkbox, LoaderSpinner } from '../../../components';
5
5
  import { WIDGET_TYPE } from '../../../types';
6
6
  import { SizeFilter } from './size-filter';
7
7
  import { toggleFacet } from '@theme/redux/reducers/category';
8
8
  import { commonProductAttributes } from '@theme/settings';
9
9
  import { useRouter } from '@akinon/next/hooks';
10
+ import { usePathname } from 'next/navigation';
11
+ import { useState } from 'react';
10
12
 
11
13
  const COMPONENT_TYPES = {
12
14
  [WIDGET_TYPE.category]: Radio,
@@ -19,6 +21,8 @@ const sizeKey = commonProductAttributes.find(
19
21
 
20
22
  interface Props {
21
23
  facet: Facet;
24
+ isPending: boolean;
25
+ startTransition: (callback: () => void) => void;
22
26
  }
23
27
 
24
28
  const sortByPredefinedOrder = (
@@ -78,16 +82,33 @@ const getComponentByWidgetType = (widgetType: string, facetKey: string) => {
78
82
  return COMPONENT_TYPES[widgetType] || COMPONENT_TYPES[WIDGET_TYPE.category];
79
83
  };
80
84
 
81
- export const FilterItem = ({ facet }: Props) => {
85
+ export const FilterItem = ({ facet, isPending, startTransition }: Props) => {
82
86
  const dispatch = useAppDispatch();
83
87
  const router = useRouter();
84
-
85
- const handleSelectFilter = (choice: FacetChoice) => {
86
- if (facet.key === 'category_ids') {
87
- router.push(choice.url);
88
- } else {
89
- dispatch(toggleFacet({ facet, choice }));
90
- }
88
+ const pathname = usePathname();
89
+
90
+ const [pendingChoice, setPendingChoice] = useState<string | null>(null);
91
+
92
+ const handleSelectFilter = ({
93
+ facet,
94
+ choice
95
+ }: {
96
+ facet: Facet;
97
+ choice: FacetChoice;
98
+ }) => {
99
+ setPendingChoice(choice.label);
100
+ startTransition(() => {
101
+ if (facet.key === 'category_ids') {
102
+ router.push(choice.url);
103
+ } else {
104
+ dispatch(toggleFacet({ facet, choice }));
105
+ }
106
+ setPendingChoice(null);
107
+
108
+ const urlSearchParams = new URLSearchParams(window.location.search);
109
+ urlSearchParams.delete(facet.search_key);
110
+ router.replace(pathname + '?' + urlSearchParams.toString());
111
+ });
91
112
  };
92
113
 
93
114
  const Component = getComponentByWidgetType(facet.widget_type, facet.key);
@@ -107,23 +128,34 @@ export const FilterItem = ({ facet }: Props) => {
107
128
  })}
108
129
  >
109
130
  {choices.map((choice, index) => (
110
- <Component
111
- key={choice.label}
112
- data={choice}
113
- name={facet.key}
114
- onChange={() => facet.key !== sizeKey && handleSelectFilter(choice)}
115
- onClick={() => facet.key === sizeKey && handleSelectFilter(choice)}
116
- checked={choice.is_selected}
117
- data-testid={`${choice.label.trim()}`}
118
- >
119
- {choice.label} (
120
- <span
121
- data-testid={`filter-count-${facet.name.toLowerCase()}-${index}`}
131
+ <div key={choice.label} className="relative">
132
+ <Component
133
+ key={choice.label}
134
+ data={choice}
135
+ name={facet.key}
136
+ onChange={() => handleSelectFilter({ facet, choice })}
137
+ onClick={() =>
138
+ facet.key === sizeKey && handleSelectFilter({ facet, choice })
139
+ }
140
+ checked={choice.is_selected}
141
+ data-testid={`${choice.label.trim()}`}
142
+ disabled={isPending}
122
143
  >
123
- {choice.quantity}
124
- </span>
125
- )
126
- </Component>
144
+ {choice.label} (
145
+ <span
146
+ data-testid={`filter-count-${facet.name.toLowerCase()}-${index}`}
147
+ >
148
+ {choice.quantity}
149
+ </span>
150
+ )
151
+ </Component>
152
+
153
+ {isPending && pendingChoice === choice.label && (
154
+ <div className="absolute inset-0 flex items-center justify-center z-50">
155
+ <LoaderSpinner />
156
+ </div>
157
+ )}
158
+ </div>
127
159
  ))}
128
160
  </div>
129
161
  </Accordion>
@@ -1,13 +1,12 @@
1
1
  'use client';
2
2
 
3
3
  import clsx from 'clsx';
4
-
5
4
  import { Button, Icon } from '@theme/components';
6
5
  import { useLocalization } from '@akinon/next/hooks';
7
6
  import { useAppDispatch, useAppSelector } from '@akinon/next/redux/hooks';
8
7
  import { resetSelectedFacets } from '@theme/redux/reducers/category';
9
8
  import CategoryActiveFilters from '@theme/views/category/category-active-filters';
10
- import { useMemo } from 'react';
9
+ import { useMemo, useState, useTransition } from 'react';
11
10
  import { FilterItem } from './filter-item';
12
11
 
13
12
  interface Props {
@@ -21,12 +20,11 @@ export const Filters = (props: Props) => {
21
20
  const { t } = useLocalization();
22
21
  const { isMenuOpen, setIsMenuOpen } = props;
23
22
 
23
+ const [isPending, startTransition] = useTransition();
24
+
24
25
  const haveFilter = useMemo(() => {
25
- return (
26
- facets.filter(
27
- (facet) =>
28
- facet.data.choices.filter((choice) => choice.is_selected).length > 0
29
- ).length > 0
26
+ return facets.some((facet) =>
27
+ facet.data.choices.some((choice) => choice.is_selected)
30
28
  );
31
29
  }, [facets]);
32
30
 
@@ -51,12 +49,22 @@ export const Filters = (props: Props) => {
51
49
  <span className="text-sm">1 {t('category.filters.results')}</span>
52
50
  <span>{t('category.filters.ready_to_wear')}</span>
53
51
  </div>
52
+
54
53
  {facets.map((facet) => {
55
- return <FilterItem key={facet.key} facet={facet} />;
54
+ return (
55
+ <FilterItem
56
+ key={facet.key}
57
+ facet={facet}
58
+ isPending={isPending}
59
+ startTransition={startTransition}
60
+ />
61
+ );
56
62
  })}
63
+
57
64
  <div className="lg:hidden">
58
65
  <CategoryActiveFilters />
59
66
  </div>
67
+
60
68
  {haveFilter && (
61
69
  <div className="lg:hidden">
62
70
  <Button
@@ -1,15 +1,17 @@
1
1
  import clsx from 'clsx';
2
- import { GetCategoryResponse } from '@akinon/next/types';
2
+ import { BreadcrumbResultType, GetCategoryResponse } from '@akinon/next/types';
3
3
  import Breadcrumb from '@theme/views/breadcrumb';
4
4
  import { CategoryBanner } from '@theme/views/category/category-banner';
5
5
  import ListPage from '@theme/views/category/category-info';
6
6
 
7
7
  export default async function Layout({
8
8
  data,
9
- children
9
+ children,
10
+ breadcrumbData
10
11
  }: {
11
12
  data: GetCategoryResponse;
12
13
  children?: React.ReactNode;
14
+ breadcrumbData?: BreadcrumbResultType[];
13
15
  }) {
14
16
  return (
15
17
  <>
@@ -28,7 +30,7 @@ export default async function Layout({
28
30
  'lg:absolute lg:inset-x-0 z-10 container lg:my-4 mx-auto'
29
31
  )}
30
32
  >
31
- <Breadcrumb />
33
+ <Breadcrumb breadcrumbList={breadcrumbData} />
32
34
  </div>
33
35
  <CategoryBanner {...data.category?.attributes?.category_banner} />
34
36
  </div>
@@ -9,6 +9,7 @@ import { twMerge } from 'tailwind-merge';
9
9
  import * as yup from 'yup';
10
10
  import { useEffect, useState } from 'react';
11
11
  import { getPosError } from '@akinon/next/utils';
12
+ import { useMessageListener } from '@akinon/next/hooks';
12
13
 
13
14
  interface FormValues {
14
15
  agreement: boolean;
@@ -25,7 +26,6 @@ const formSchema = () =>
25
26
  export default function RedirectionPayment() {
26
27
  const { payment_option } = useAppSelector((state) => state.checkout.preOrder);
27
28
  const [formError, setFormError] = useState(null);
28
-
29
29
  const {
30
30
  register,
31
31
  handleSubmit,
@@ -34,11 +34,12 @@ export default function RedirectionPayment() {
34
34
  resolver: yupResolver(formSchema())
35
35
  });
36
36
  const [completeRedirectionPayment] = useCompleteRedirectionPaymentMutation();
37
-
38
37
  const onSubmit = async () => {
39
38
  completeRedirectionPayment();
40
39
  };
41
40
 
41
+ useMessageListener();
42
+
42
43
  useEffect(() => {
43
44
  const posErrors = getPosError();
44
45
 
@@ -48,44 +49,49 @@ export default function RedirectionPayment() {
48
49
  }, []);
49
50
 
50
51
  return (
51
- <form onSubmit={handleSubmit(onSubmit)} className="lg-5 space-y-5 lg:p-10">
52
- <h1 className="text-2xl font-bold px-4 md:px-0">
53
- Pay With {payment_option.name}
54
- </h1>
55
-
56
- <p className="px-4 md:px-0">
57
- You can quickly and easily pay and complete your order with{' '}
58
- {payment_option.name}.
59
- </p>
60
-
61
- <Checkbox
62
- className="px-4 md:px-0"
63
- {...register('agreement')}
64
- error={errors.agreement}
52
+ <div className="checkout-redirection-payment-wrapper">
53
+ <form
54
+ onSubmit={handleSubmit(onSubmit)}
55
+ className="lg-5 space-y-5 lg:p-10"
65
56
  >
66
- Check here to indicate that you have read and agree to the all terms.
67
- </Checkbox>
57
+ <h1 className="text-2xl font-bold px-4 md:px-0">
58
+ Pay With {payment_option.name}
59
+ </h1>
68
60
 
69
- {formError?.non_field_errors && (
70
- <div
71
- className="w-full text-xs text-start px-1 mt-3 text-error"
72
- data-testid="checkout-form-error"
73
- >
74
- {formError.non_field_errors}
75
- </div>
76
- )}
77
- {formError?.status && (
78
- <div
79
- className="w-full text-xs text-start px-1 mt-3 text-error"
80
- data-testid="checkout-form-error"
61
+ <p className="px-4 md:px-0">
62
+ You can quickly and easily pay and complete your order with{' '}
63
+ {payment_option.name}.
64
+ </p>
65
+
66
+ <Checkbox
67
+ className="px-4 md:px-0"
68
+ {...register('agreement')}
69
+ error={errors.agreement}
81
70
  >
82
- {formError.status}
83
- </div>
84
- )}
71
+ Check here to indicate that you have read and agree to the all terms.
72
+ </Checkbox>
73
+
74
+ {formError?.non_field_errors && (
75
+ <div
76
+ className="w-full text-xs text-start px-1 mt-3 text-error"
77
+ data-testid="checkout-form-error"
78
+ >
79
+ {formError.non_field_errors}
80
+ </div>
81
+ )}
82
+ {formError?.status && (
83
+ <div
84
+ className="w-full text-xs text-start px-1 mt-3 text-error"
85
+ data-testid="checkout-form-error"
86
+ >
87
+ {formError.status}
88
+ </div>
89
+ )}
85
90
 
86
- <Button className={twMerge('w-full md:w-36 px-4 md:px-0')}>
87
- {payment_option.name}
88
- </Button>
89
- </form>
91
+ <Button className={twMerge('w-full md:w-36 px-4 md:px-0')}>
92
+ {payment_option.name}
93
+ </Button>
94
+ </form>
95
+ </div>
90
96
  );
91
97
  }
@@ -5,9 +5,12 @@ import { useSetPaymentOptionMutation } from '@akinon/next/data/client/checkout';
5
5
  import { CheckoutPaymentOption } from '@akinon/next/types';
6
6
  import { Radio } from '@theme/components';
7
7
  import { usePaymentOptions } from '@akinon/next/hooks/use-payment-options';
8
+ import { useMemo } from 'react';
8
9
 
9
10
  const PaymentOptionButtons = () => {
10
- const { preOrder } = useAppSelector((state: RootState) => state.checkout);
11
+ const { preOrder, attributeBasedShippingOptions } = useAppSelector(
12
+ (state: RootState) => state.checkout
13
+ );
11
14
  const [setPaymentOption] = useSetPaymentOptionMutation();
12
15
  const { filteredPaymentOptions } = usePaymentOptions();
13
16
 
@@ -22,10 +25,23 @@ const PaymentOptionButtons = () => {
22
25
  });
23
26
  };
24
27
 
28
+ const displayedPaymentOptions = useMemo(() => {
29
+ if (
30
+ attributeBasedShippingOptions &&
31
+ Object.keys(attributeBasedShippingOptions).length > 0
32
+ ) {
33
+ return filteredPaymentOptions.filter(
34
+ (option) => option.slug.toLowerCase() !== 'pay-on-delivery'
35
+ );
36
+ }
37
+
38
+ return filteredPaymentOptions;
39
+ }, [filteredPaymentOptions, attributeBasedShippingOptions]);
40
+
25
41
  return (
26
42
  <>
27
43
  <div className="w-full space-y-4 px-4 flex flex-col mb-8 md:hidden">
28
- {filteredPaymentOptions.map((option) => (
44
+ {displayedPaymentOptions.map((option) => (
29
45
  <label
30
46
  key={`payment-option-${option.pk}`}
31
47
  className="border px-4 py-3 mt-3 flex h-12"
@@ -47,7 +63,7 @@ const PaymentOptionButtons = () => {
47
63
  </div>
48
64
 
49
65
  <div className="hidden md:flex">
50
- {filteredPaymentOptions.map((option) => (
66
+ {displayedPaymentOptions.map((option) => (
51
67
  <button
52
68
  key={`payment-option-${option.pk}`}
53
69
  onClick={() => onClickHandler(option)}
@@ -1,36 +1,84 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
1
2
  import { useAppDispatch, useAppSelector } from '@akinon/next/redux/hooks';
2
- import { setCurrentStep } from '@akinon/next/redux/reducers/checkout';
3
+ import {
4
+ setCurrentStep,
5
+ setSelectedShippingOptions
6
+ } from '@akinon/next/redux/reducers/checkout';
3
7
  import { RootState } from '@theme/redux/store';
4
8
  import {
5
9
  useSetShippingOptionMutation,
10
+ useSetAttributeBasedShippingOptionsMutation,
6
11
  useSetDataSourceShippingOptionsMutation
7
12
  } from '@akinon/next/data/client/checkout';
8
13
  import { Price, Button, Radio } from '@theme/components';
9
14
  import { CheckoutStep } from '@akinon/next/types';
10
15
  import { useLocalization } from '@akinon/next/hooks';
11
- import { useEffect, useState } from 'react';
12
16
 
13
- const ShippingOptions = () => {
17
+ const ShippingOptions: React.FC = () => {
14
18
  const { t } = useLocalization();
19
+ const dispatch = useAppDispatch();
15
20
  const {
16
21
  steps,
17
22
  shippingOptions,
23
+ attributeBasedShippingOptions,
18
24
  dataSourceShippingOptions,
19
25
  preOrder,
20
- addressList
26
+ addressList,
27
+ selectedShippingOptions
21
28
  } = useAppSelector((state: RootState) => state.checkout);
22
- const { shipping_option, shipping_address, data_source_shipping_options } =
23
- preOrder ?? {};
29
+ const {
30
+ shipping_option,
31
+ shipping_address,
32
+ attribute_based_shipping_options,
33
+ data_source_shipping_options
34
+ } = preOrder ?? {};
35
+
36
+ const [setShippingOption] = useSetShippingOptionMutation();
37
+ const [setAttributeBasedShippingOptions] =
38
+ useSetAttributeBasedShippingOptionsMutation();
39
+ const [setDataSourceShippingOption] =
40
+ useSetDataSourceShippingOptionsMutation();
41
+
42
+ const prevAttributeBasedOptionsRef = useRef(attributeBasedShippingOptions);
24
43
 
25
44
  const [selectedPks, setSelectedPks] = useState<
26
45
  { dataSourcePk: number; optionPk: number }[] | null
27
46
  >(null);
28
47
 
29
- const [setShippingOption] = useSetShippingOptionMutation();
30
- const [setDataSourceShippingOption] =
31
- useSetDataSourceShippingOptionsMutation();
48
+ const initializeSelectedOptions = useCallback(() => {
49
+ if (attribute_based_shipping_options) {
50
+ const newSelected = { ...selectedShippingOptions };
51
+ let hasChanges = false;
32
52
 
33
- const dispatch = useAppDispatch();
53
+ Object.entries(attributeBasedShippingOptions).forEach(
54
+ ([color, options]) => {
55
+ if (
56
+ !newSelected[color] ||
57
+ !options.some((opt) => opt.pk === newSelected[color])
58
+ ) {
59
+ newSelected[color] = options[0].pk;
60
+ hasChanges = true;
61
+ }
62
+ }
63
+ );
64
+
65
+ if (hasChanges) {
66
+ dispatch(setSelectedShippingOptions(newSelected));
67
+ setAttributeBasedShippingOptions(newSelected);
68
+ }
69
+ } else if (shippingOptions.length > 0 && !shipping_option) {
70
+ setShippingOption(shippingOptions[0].pk);
71
+ }
72
+ // eslint-disable-next-line react-hooks/exhaustive-deps
73
+ }, [
74
+ attributeBasedShippingOptions,
75
+ selectedShippingOptions,
76
+ setAttributeBasedShippingOptions,
77
+ attribute_based_shipping_options,
78
+ shippingOptions,
79
+ shipping_option,
80
+ setShippingOption
81
+ ]);
34
82
 
35
83
  useEffect(() => {
36
84
  if (!data_source_shipping_options) return;
@@ -43,6 +91,49 @@ const ShippingOptions = () => {
43
91
  setSelectedPks(initialSelectedPks);
44
92
  }, [data_source_shipping_options]);
45
93
 
94
+ useEffect(() => {
95
+ if (
96
+ JSON.stringify(prevAttributeBasedOptionsRef.current) !==
97
+ JSON.stringify(attributeBasedShippingOptions) ||
98
+ Object.keys(selectedShippingOptions).length === 0 ||
99
+ (!shipping_option && shippingOptions.length > 0)
100
+ ) {
101
+ initializeSelectedOptions();
102
+ prevAttributeBasedOptionsRef.current = attributeBasedShippingOptions;
103
+ }
104
+ }, [
105
+ attributeBasedShippingOptions,
106
+ selectedShippingOptions,
107
+ initializeSelectedOptions,
108
+ shipping_option,
109
+ shippingOptions
110
+ ]);
111
+
112
+ const handleAttributeBasedOptionChange = useCallback(
113
+ (color: string, newPk: number) => {
114
+ const updatedOptions = { ...selectedShippingOptions, [color]: newPk };
115
+ dispatch(setSelectedShippingOptions(updatedOptions));
116
+ setAttributeBasedShippingOptions(updatedOptions);
117
+ },
118
+ // eslint-disable-next-line react-hooks/exhaustive-deps
119
+ [selectedShippingOptions, setAttributeBasedShippingOptions]
120
+ );
121
+
122
+ if (addressList.length < 1) {
123
+ return (
124
+ <div className="w-full lg:w-2/5">
125
+ <div className="border-b border-gray-400 px-8 py-4">
126
+ <h2 className="text-2xl">{t('checkout.address.shipping.title')}</h2>
127
+ </div>
128
+ <div className="py-4 px-8">
129
+ <p className="text-xs">
130
+ {t('checkout.address.shipping.select_address_to_continue')}
131
+ </p>
132
+ </div>
133
+ </div>
134
+ );
135
+ }
136
+
46
137
  const updateData = (dataSourcePk: number, newPk: number) => {
47
138
  const updatedSelectedPks = selectedPks?.map((item) =>
48
139
  item.dataSourcePk === dataSourcePk ? { ...item, optionPk: newPk } : item
@@ -106,58 +197,79 @@ const ShippingOptions = () => {
106
197
  </span>
107
198
  </div>
108
199
  ))}
109
- <Button
110
- className="mt-2 w-full"
111
- disabled={!steps.shipping.completed}
112
- onClick={() => dispatch(setCurrentStep(CheckoutStep.Payment))}
113
- data-testid="checkout-shipping-save"
114
- >
115
- {t('checkout.address.shipping.button')}
116
- </Button>
117
200
  </>
118
201
  )}
119
- {dataSourceShippingOptions && dataSourceShippingOptions.length > 0 && (
120
- <>
121
- {dataSourceShippingOptions.map((option) => (
122
- <div key={option.pk}>
123
- <h3 className="text-lg font-bold">{option?.name}</h3>
124
- {option.data_source_shipping_options.map((opt) => (
202
+
203
+ {attributeBasedShippingOptions &&
204
+ Object.keys(attributeBasedShippingOptions).length > 0 &&
205
+ Object.entries(attributeBasedShippingOptions).map(
206
+ ([color, options]) => (
207
+ <div key={color}>
208
+ <h3 className="text-lg font-bold">{color}</h3>
209
+ {options.map((option) => (
125
210
  <div
126
- key={opt.pk}
211
+ key={option.pk}
127
212
  className="py-4 border-t border-gray-400 flex justify-between"
128
213
  >
129
214
  <Radio
130
- name={`data-source-shipping-${option.pk}`}
131
- checked={
132
- selectedPks?.some(
133
- (item) => item.optionPk === opt.pk
134
- ) || false
135
- }
136
- onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
137
- handleRadioChange(e, option.pk)
215
+ name={`attribute-based-shipping-${color}`}
216
+ checked={selectedShippingOptions[color] === option.pk}
217
+ onChange={() =>
218
+ handleAttributeBasedOptionChange(color, option.pk)
138
219
  }
139
- value={opt.pk}
140
- data-testid={`checkout-data-source-shipping-${opt.pk}`}
220
+ data-testid={`checkout-attribute-based-shipping-${option.pk}`}
141
221
  >
142
- {opt?.shipping_option_name}
222
+ {`${option.shipping_option_name}`}
143
223
  </Radio>
144
224
  <span className="text-xs">
145
- <Price value={opt?.shipping_amount} />
225
+ <Price value={option.shipping_amount} />
146
226
  </span>
147
227
  </div>
148
228
  ))}
149
229
  </div>
150
- ))}
151
- <Button
152
- className="mt-2 w-full"
153
- disabled={!steps.shipping.completed}
154
- onClick={() => dispatch(setCurrentStep(CheckoutStep.Payment))}
155
- data-testid="checkout-shipping-save"
156
- >
157
- {t('checkout.address.shipping.button')}
158
- </Button>
159
- </>
160
- )}
230
+ )
231
+ )}
232
+
233
+ {dataSourceShippingOptions &&
234
+ dataSourceShippingOptions.length > 0 &&
235
+ dataSourceShippingOptions.map((option) => (
236
+ <div key={option.pk}>
237
+ <h3 className="text-lg font-bold">{option?.name}</h3>
238
+ {option.data_source_shipping_options.map((opt) => (
239
+ <div
240
+ key={opt.pk}
241
+ className="py-4 border-t border-gray-400 flex justify-between"
242
+ >
243
+ <Radio
244
+ name={`data-source-shipping-${option.pk}`}
245
+ checked={
246
+ selectedPks?.some((item) => item.optionPk === opt.pk) ||
247
+ false
248
+ }
249
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
250
+ handleRadioChange(e, option.pk)
251
+ }
252
+ value={opt.pk}
253
+ data-testid={`checkout-data-source-shipping-${opt.pk}`}
254
+ >
255
+ {opt?.shipping_option_name}
256
+ </Radio>
257
+ <span className="text-xs">
258
+ <Price value={opt?.shipping_amount} />
259
+ </span>
260
+ </div>
261
+ ))}
262
+ </div>
263
+ ))}
264
+
265
+ <Button
266
+ className="mt-2 w-full"
267
+ disabled={!steps.shipping.completed}
268
+ onClick={() => dispatch(setCurrentStep(CheckoutStep.Payment))}
269
+ data-testid="checkout-shipping-save"
270
+ >
271
+ {t('checkout.address.shipping.button')}
272
+ </Button>
161
273
  </div>
162
274
  )}
163
275
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akinon/projectzero",
3
- "version": "1.65.0",
3
+ "version": "1.67.0",
4
4
  "private": false,
5
5
  "description": "CLI tool to manage your Project Zero Next project",
6
6
  "bin": {