@akinon/next 1.9.0 → 1.11.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,24 @@
1
1
  # @akinon/next
2
2
 
3
+ ## 1.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ZERO-2355: Add LoaderSpinner component
8
+ - ZERO-2305: Add endpoints for B2B Basket
9
+ - ZERO-2319: Show 3D & redirection payment errors
10
+ - ZERO-2353: Add Icon component
11
+ - ZERO-2357: Add Radio component
12
+ - ZERO-2307: Prevent multiple mutation calls
13
+ - ZERO-2240: Add endpoints and types for dynamic forms
14
+
15
+ ## 1.10.0
16
+
17
+ ### Minor Changes
18
+
19
+ - ZERO-2330: Fix logout endpoint
20
+ - ZERO-2327: Add service for bulk cancellation
21
+
3
22
  ## 1.9.0
4
23
 
5
24
  ### Minor Changes
@@ -0,0 +1,18 @@
1
+ import { IconProps } from '../types/index';
2
+ import clsx from 'clsx';
3
+
4
+ export const Icon = (props: IconProps) => {
5
+ const { name, size, className, ...rest } = props;
6
+
7
+ return (
8
+ <i
9
+ className={clsx(`flex pz-icon-${name}`, className)}
10
+ {...rest}
11
+ style={
12
+ size && {
13
+ fontSize: `${size}px`
14
+ }
15
+ }
16
+ />
17
+ );
18
+ };
@@ -0,0 +1,23 @@
1
+ import { twMerge } from 'tailwind-merge';
2
+
3
+ type LoaderSpinnerProps = {
4
+ className?: string;
5
+ borderType?: 'solid' | 'dotted' | 'dashed';
6
+ };
7
+
8
+ export const LoaderSpinner: React.FC<LoaderSpinnerProps> = ({
9
+ borderType = 'solid',
10
+ className
11
+ }) => {
12
+ return (
13
+ <div className="w-full h-full flex justify-center items-center">
14
+ <div
15
+ className={twMerge(
16
+ 'w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin',
17
+ `border-${borderType}`,
18
+ className
19
+ )}
20
+ />
21
+ </div>
22
+ );
23
+ };
@@ -0,0 +1,18 @@
1
+ import { forwardRef } from 'react';
2
+ import { RadioProps } from '../types/index';
3
+ import { twMerge } from 'tailwind-merge';
4
+
5
+ const Radio = forwardRef<HTMLInputElement, RadioProps>((props, ref) => {
6
+ const { children, ...rest } = props;
7
+
8
+ return (
9
+ <label className={twMerge('flex items-center text-xs', props.className)}>
10
+ <input type="radio" {...rest} ref={ref} className="w-4 h-4" />
11
+ {children && <span className="text-xs ml-2">{children}</span>}
12
+ </label>
13
+ );
14
+ });
15
+
16
+ Radio.displayName = 'Radio';
17
+
18
+ export { Radio };
@@ -130,6 +130,15 @@ const accountApi = api.injectEndpoints({
130
130
  body
131
131
  })
132
132
  }),
133
+ bulkCancellation: builder.mutation<void, AccountOrderCancellation>({
134
+ query: (body) => ({
135
+ url: buildClientRequestUrl(account.bulkCancellationRequest, {
136
+ contentType: 'application/json'
137
+ }),
138
+ method: 'POST',
139
+ body
140
+ })
141
+ }),
133
142
  getCancellationReasons: builder.query<AccountOrderCancellationReason, void>(
134
143
  {
135
144
  query: () => buildClientRequestUrl(account.cancellationReasons)
@@ -186,6 +195,7 @@ export const {
186
195
  useUpdateProfileMutation,
187
196
  useGetProfileInfoQuery,
188
197
  useCancelOrderMutation,
198
+ useBulkCancellationMutation,
189
199
  useGetCancellationReasonsQuery,
190
200
  useGetBasketOffersQuery,
191
201
  useGetFutureBasketOffersQuery,
@@ -1,10 +1,42 @@
1
- import { createApi, fetchBaseQuery, retry } from '@reduxjs/toolkit/query/react';
1
+ import {
2
+ createApi,
3
+ fetchBaseQuery,
4
+ retry,
5
+ BaseQueryFn,
6
+ FetchBaseQueryError,
7
+ FetchBaseQueryMeta,
8
+ FetchArgs,
9
+ BaseQueryApi
10
+ } from '@reduxjs/toolkit/query/react';
2
11
  import settings from 'settings';
3
12
  import { getCookie } from '../../utils';
13
+ import { RootState } from '@theme/redux/store';
4
14
 
5
- export const api = createApi({
6
- reducerPath: 'api',
7
- baseQuery: retry(fetchBaseQuery({
15
+ interface CustomBaseQueryApi extends BaseQueryApi {
16
+ getState: () => RootState;
17
+ }
18
+
19
+ const customBaseQuery: BaseQueryFn<
20
+ string | FetchArgs,
21
+ unknown,
22
+ FetchBaseQueryError,
23
+ {},
24
+ FetchBaseQueryMeta
25
+ > = async (args, api: CustomBaseQueryApi, extraOptions) => {
26
+ const mutations = Object.entries(api.getState()?.api?.mutations ?? {}).map(
27
+ (x) => x[1]
28
+ );
29
+
30
+ if (
31
+ api.type === 'mutation' &&
32
+ mutations.filter(
33
+ (m) => m.status === 'pending' && m.endpointName === api.endpoint
34
+ ).length > 1
35
+ ) {
36
+ api.abort('Mutation already in progress.');
37
+ }
38
+
39
+ const baseQuery = fetchBaseQuery({
8
40
  prepareHeaders: async (headers) => {
9
41
  const csrfCookie = getCookie('csrftoken');
10
42
  const activeLocale = getCookie('pz-locale');
@@ -19,22 +51,32 @@ export const api = createApi({
19
51
  if (csrfCookie) {
20
52
  headers.set('x-csrftoken', `${csrfCookie}`);
21
53
  }
22
-
23
54
  return headers;
24
55
  },
25
56
  credentials: 'include'
26
- }), {
27
- maxRetries: 3
28
- }),
57
+ });
58
+ try {
59
+ const result = await baseQuery(args, api, extraOptions);
60
+ return result;
61
+ } catch (error) {
62
+ return { error };
63
+ }
64
+ };
65
+
66
+ export const api = createApi({
67
+ reducerPath: 'api',
68
+ baseQuery: retry(customBaseQuery, { maxRetries: 3 }),
29
69
  tagTypes: [
30
70
  'Basket',
71
+ 'BasketB2b',
72
+ 'DraftsB2b',
31
73
  'Product',
32
74
  'Checkout',
33
75
  'Favorite',
34
76
  'Addresses',
35
77
  'Profile'
36
78
  ],
37
- endpoints: () => ({}) // it must be a function to be able to use `injectEndpoints`
79
+ endpoints: () => ({})
38
80
  });
39
81
 
40
82
  export const {
@@ -1,17 +1,27 @@
1
1
  import { buildClientRequestUrl } from '../../utils';
2
2
  import { api } from './api';
3
3
  import { b2b } from '../urls';
4
- import { Basket, BasketParams, BasketResponse, Division, GetResponse } from '../../types';
4
+ import {
5
+ Basket,
6
+ BasketParams,
7
+ BasketResponse,
8
+ Division,
9
+ DraftResponse,
10
+ GetResponse,
11
+ LoadBasketParams,
12
+ SaveBasketParams,
13
+ updateProduct,
14
+ CreateQuotation
15
+ } from '../../types';
5
16
 
6
17
  const b2bApi = api.injectEndpoints({
7
18
  endpoints: (build) => ({
8
- getBasket: build.query<Basket, void>({
19
+ getBasketB2b: build.query<BasketResponse, void>({
9
20
  query: () =>
10
21
  buildClientRequestUrl(b2b.basket, {
11
22
  contentType: 'application/json'
12
23
  }),
13
- transformResponse: (response: { basket: Basket }) => response.basket,
14
- providesTags: ['Basket']
24
+ providesTags: ['BasketB2b']
15
25
  }),
16
26
  getDivisions: build.query<GetResponse<Division>, void>({
17
27
  query: () => buildClientRequestUrl(b2b.divisions)
@@ -25,8 +35,60 @@ const b2bApi = api.injectEndpoints({
25
35
  body
26
36
  })
27
37
  }),
38
+ saveBasket: build.mutation<BasketResponse, SaveBasketParams>({
39
+ query: (body) => ({
40
+ url: buildClientRequestUrl(b2b.saveBasket, {
41
+ contentType: 'application/json'
42
+ }),
43
+ method: 'POST',
44
+ body
45
+ }),
46
+ invalidatesTags: ['BasketB2b', 'DraftsB2b']
47
+ }),
48
+ getDrafts: build.query<DraftResponse[], void>({
49
+ query: () => buildClientRequestUrl(b2b.draftBaskets),
50
+ providesTags: ['DraftsB2b']
51
+ }),
52
+ loadBasket: build.mutation<string, number>({
53
+ query: (id) => ({
54
+ url: buildClientRequestUrl(b2b.loadBasket(id), {
55
+ contentType: 'application/json'
56
+ }),
57
+ method: 'POST'
58
+ }),
59
+ invalidatesTags: ['BasketB2b']
60
+ }),
61
+ updateProduct: build.mutation<Basket, updateProduct>({
62
+ query: (body) => ({
63
+ url: buildClientRequestUrl(b2b.basket, {
64
+ contentType: 'application/json'
65
+ }),
66
+ method: 'PUT',
67
+ body
68
+ }),
69
+ invalidatesTags: ['BasketB2b']
70
+ }),
71
+ createQuotation: build.mutation<BasketResponse, CreateQuotation>({
72
+ query: (body) => ({
73
+ url: buildClientRequestUrl(b2b.myQuotations, {
74
+ contentType: 'application/json'
75
+ }),
76
+ method: 'POST',
77
+ body
78
+ }),
79
+ invalidatesTags: ['BasketB2b', 'DraftsB2b']
80
+ }),
28
81
  }),
29
82
  overrideExisting: true
30
83
  });
31
84
 
32
- export const { useGetBasketQuery, useLazyGetDivisionsQuery, useAddToBasketMutation } = b2bApi;
85
+ export const {
86
+ useGetBasketB2bQuery,
87
+ useLazyGetDivisionsQuery,
88
+ useAddToBasketMutation,
89
+ useSaveBasketMutation,
90
+ useGetDraftsQuery,
91
+ useLoadBasketMutation,
92
+ useUpdateProductMutation,
93
+ useCreateQuotationMutation
94
+ } = b2bApi;
@@ -0,0 +1,22 @@
1
+ import { Cache, CacheKey } from "../../lib/cache";
2
+ import { FormType } from "../../types/commerce/form";
3
+
4
+ import appFetch from "../../utils/app-fetch";
5
+ import { form } from "../urls";
6
+
7
+ const getFormDataHandler = (pk: number) => {
8
+ return async function () {
9
+ const data = await appFetch<FormType>(form.getForm(pk), {
10
+ headers: {
11
+ Accept: 'application/json',
12
+ 'Content-Type': 'application/json'
13
+ }
14
+ });
15
+
16
+ return data;
17
+ };
18
+ };
19
+
20
+ export const getFormData = ({ pk }: { pk: number }) => {
21
+ return Cache.wrap(CacheKey.Form(pk), getFormDataHandler(pk));
22
+ };
@@ -7,3 +7,4 @@ export * from './widget';
7
7
  export * from './seo';
8
8
  export * from './menu';
9
9
  export * from './landingpage';
10
+ export * from './form';
package/data/urls.ts CHANGED
@@ -7,6 +7,7 @@ export const account = {
7
7
  order: '/users/orders/',
8
8
  orderId: (id: string | string[]) => `/users/orders/${id}/`,
9
9
  cancelOrder: (id: string) => `/orders/${id}/cancel/`,
10
+ bulkCancellationRequest: '/users/orders/bulk_cancellation_requests/',
10
11
  cancellationReasons: '/users/orders/cancellation_reasons/',
11
12
  updatePassword: '/users/password/change/',
12
13
  updateEmail: '/users/email-change/',
@@ -144,7 +145,7 @@ export const user = {
144
145
  currentUser: '/current_user/',
145
146
  login: '/users/login/',
146
147
  register: '/users/registration/',
147
- logout: '/users/logout/',
148
+ logout: '/users/logout',
148
149
  captcha: '/users/pz-captcha/',
149
150
  profiles: '/users/profile/',
150
151
  forgotPassword: '/users/password/reset/',
@@ -168,6 +169,10 @@ export const widgets = {
168
169
  getWidget: (slug: string) => `/widgets/${slug}/`
169
170
  };
170
171
 
172
+ export const form = {
173
+ getForm: (pk: number) => `/forms/${pk}/generate/`,
174
+ };
175
+
171
176
  const URLS = {
172
177
  account,
173
178
  address,
@@ -179,7 +184,8 @@ const URLS = {
179
184
  product,
180
185
  wishlist,
181
186
  user,
182
- widgets
187
+ widgets,
188
+ form
183
189
  };
184
190
 
185
191
  const UrlProxyHandler = {
package/lib/cache.ts CHANGED
@@ -51,7 +51,8 @@ export const CacheKey = {
51
51
  Menu: (depth: number, parent?: string) =>
52
52
  `menu_${depth}${parent ? `_${parent}` : ''}`,
53
53
  Seo: (url: string) => `seo_${url}`,
54
- RootSeo: 'root_seo'
54
+ RootSeo: 'root_seo',
55
+ Form: (pk: number) => `form_${pk}`
55
56
  };
56
57
 
57
58
  export class Cache {
@@ -4,7 +4,6 @@ import Settings from 'settings';
4
4
  import logger from '../utils/log';
5
5
  import { PzNextRequest } from '.';
6
6
  import { getUrlPathWithLocale } from '../utils/localization';
7
- import { urlLocaleMatcherRegex } from '../utils';
8
7
 
9
8
  const streamToString = async (stream: ReadableStream<Uint8Array> | null) => {
10
9
  if (stream) {
@@ -33,10 +32,6 @@ const withRedirectionPayment =
33
32
  (middleware: NextMiddleware) =>
34
33
  async (req: PzNextRequest, event: NextFetchEvent) => {
35
34
  const url = req.nextUrl.clone();
36
- const pathnameWithoutLocale = url.pathname.replace(
37
- urlLocaleMatcherRegex,
38
- ''
39
- );
40
35
  const searchParams = new URLSearchParams(url.search);
41
36
  const ip = req.headers.get('x-forwarded-for') ?? '';
42
37
 
@@ -70,12 +65,34 @@ const withRedirectionPayment =
70
65
 
71
66
  const response = await request.json();
72
67
 
73
- const { context_list: contextList } = response;
68
+ const { context_list: contextList, errors } = response;
74
69
  const redirectionContext = contextList?.find(
75
70
  (context) => context.page_context?.redirect_url
76
71
  );
77
72
  const redirectUrl = redirectionContext?.page_context?.redirect_url;
78
73
 
74
+ if (errors && Object.keys(errors).length) {
75
+ logger.error('Error while completing redirection payment', {
76
+ middleware: 'redirection-payment',
77
+ errors,
78
+ requestHeaders,
79
+ ip
80
+ });
81
+
82
+ return NextResponse.redirect(
83
+ `${url.origin}${getUrlPathWithLocale(
84
+ '/orders/checkout/',
85
+ req.cookies.get('pz-locale')?.value
86
+ )}`,
87
+ {
88
+ status: 303,
89
+ headers: {
90
+ 'Set-Cookie': `pz-pos-error=${JSON.stringify(errors)}; path=/;`
91
+ }
92
+ }
93
+ );
94
+ }
95
+
79
96
  logger.info('Order success page context list', {
80
97
  middleware: 'redirection-payment',
81
98
  contextList,
@@ -64,12 +64,34 @@ const withThreeDRedirection =
64
64
 
65
65
  const response = await request.json();
66
66
 
67
- const { context_list: contextList } = response;
67
+ const { context_list: contextList, errors } = response;
68
68
  const redirectionContext = contextList?.find(
69
69
  (context) => context.page_context?.redirect_url
70
70
  );
71
71
  const redirectUrl = redirectionContext?.page_context?.redirect_url;
72
72
 
73
+ if (errors && Object.keys(errors).length) {
74
+ logger.error('Error while completing 3D payment', {
75
+ middleware: 'three-d-redirection',
76
+ errors,
77
+ requestHeaders,
78
+ ip
79
+ });
80
+
81
+ return NextResponse.redirect(
82
+ `${url.origin}${getUrlPathWithLocale(
83
+ '/orders/checkout/',
84
+ req.cookies.get('pz-locale')?.value
85
+ )}`,
86
+ {
87
+ status: 303,
88
+ headers: {
89
+ 'Set-Cookie': `pz-pos-error=${JSON.stringify(errors)}; path=/;`
90
+ }
91
+ }
92
+ );
93
+ }
94
+
73
95
  logger.info('Order success page context list', {
74
96
  middleware: 'three-d-redirection',
75
97
  contextList,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@akinon/next",
3
3
  "description": "Core package for Project Zero Next",
4
- "version": "1.9.0",
4
+ "version": "1.11.0",
5
5
  "private": false,
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -23,6 +23,10 @@ export type Division = {
23
23
  };
24
24
 
25
25
  interface ProductB2b extends Product {
26
+ sku: string;
27
+ name: string;
28
+ price: string;
29
+ base_code: string;
26
30
  asorti: string;
27
31
  category: string;
28
32
  currency: string;
@@ -41,21 +45,69 @@ export type BasketResponse = {
41
45
  total_amount: string;
42
46
  price: string;
43
47
  quantity: number;
44
- divisions: Division[];
48
+ divisions: BasketItemDivision[];
45
49
  product: ProductB2b;
50
+ product_remote_id: number;
46
51
  }
47
52
  ];
48
53
  };
49
54
 
55
+ export interface QuotationErrorType {
56
+ data: {
57
+ non_field_errors: string[];
58
+ };
59
+ }
60
+
61
+ export interface BasketItemDivision {
62
+ id: number;
63
+ name: string;
64
+ erp_code: string;
65
+ quantity: number;
66
+ }
67
+
68
+ export type BasketItemType = {
69
+ total_amount: string;
70
+ price: string;
71
+ quantity: number;
72
+ divisions: BasketItemDivision[];
73
+ product: ProductB2b;
74
+ product_remote_id: number;
75
+ }
76
+
50
77
  export type BasketParams = {
51
78
  division: string;
52
79
  product_remote_id: string;
53
80
  quantity: string;
54
81
  };
55
82
 
83
+ export type SaveBasketParams = {
84
+ name: string;
85
+ };
86
+
87
+ export type CreateQuotation = {
88
+ name: string;
89
+ };
90
+
91
+ export type updateProduct = {
92
+ product_remote_id: number;
93
+ division: number;
94
+ quantity: number
95
+ };
96
+
97
+ export type LoadBasketParams = {
98
+ id: number;
99
+ };
100
+
56
101
  export interface GetResponse<T> {
57
102
  count: number;
58
103
  next: null;
59
104
  previous: null;
60
105
  results: T[];
61
106
  }
107
+
108
+ export type DraftResponse = {
109
+ id: number;
110
+ name: string;
111
+ total_amount: number;
112
+ total_quantity: number;
113
+ };
@@ -0,0 +1,66 @@
1
+ import { InputHTMLAttributes, HTMLAttributes } from 'react';
2
+
3
+ export type Validator = {
4
+ regex: {
5
+ regex: string;
6
+ message: string;
7
+ inverse_match: boolean;
8
+ };
9
+ max_length: number;
10
+ required: boolean;
11
+ min_length: number;
12
+ max_value: number;
13
+ min_value: number;
14
+ };
15
+
16
+ export type FormField = {
17
+ chosen: boolean;
18
+ input_type: string;
19
+ id: string;
20
+ key: string;
21
+ validators: Validator;
22
+ label: string;
23
+ order: number;
24
+ class?: string;
25
+ attributes?: object | null;
26
+ labelClass?: string;
27
+ wrapperClass?: string;
28
+ placeholder?: string;
29
+ choices?: string[];
30
+ [key: string]: any;
31
+ };
32
+
33
+ export type Schema = FormField[];
34
+
35
+ export type FormType = {
36
+ pk: number;
37
+ schema: Schema;
38
+ template: string;
39
+ is_active: boolean;
40
+ url: string;
41
+ name: string;
42
+ pretty_url: any;
43
+ created_date: string;
44
+ modified_date: string;
45
+ formprettyurl_set: any[];
46
+ translations: null | any;
47
+ };
48
+
49
+ export type FieldPropertiesType = {
50
+ key?: string;
51
+ className?: string | Record<string, boolean>;
52
+ attributes?: InputHTMLAttributes<object> | HTMLAttributes<object>;
53
+ labelClassName?: string;
54
+ wrapperClassName?: string;
55
+ };
56
+
57
+ export type AllFieldClassesType = {
58
+ className?: string | undefined;
59
+ labelClassName?: string | undefined;
60
+ wrapperClassName?: string | undefined;
61
+ };
62
+
63
+ export type FormPropertiesType = {
64
+ actionUrl: string;
65
+ className?: string;
66
+ };
@@ -9,3 +9,4 @@ export * from './widget';
9
9
  export * from './flatpage';
10
10
  export * from './order';
11
11
  export * from './b2b';
12
+ export * from './form';
package/types/index.ts CHANGED
@@ -222,7 +222,15 @@ export interface RootLayoutProps<
222
222
  };
223
223
  }
224
224
 
225
+ export type RadioProps = React.HTMLProps<HTMLInputElement>;
226
+
225
227
  export interface AuthError {
226
228
  type: string;
227
229
  data?: any;
228
230
  }
231
+
232
+ export interface IconProps extends React.ComponentPropsWithRef<'i'> {
233
+ name: string;
234
+ size?: number;
235
+ className?: string;
236
+ }
package/utils/index.ts CHANGED
@@ -32,7 +32,7 @@ export function setCookie(name: string, val: string) {
32
32
  export function removeCookie(name: string) {
33
33
  const date = 'Thu, 01 Jan 1970 00:00:00 UTC';
34
34
 
35
- document.cookie = name + '=' + '; expires=' + date + '; path=/';
35
+ document.cookie = `${name}=; expires=${date}; path=/;`;
36
36
  }
37
37
 
38
38
  /**
@@ -137,3 +137,14 @@ export const urlLocaleMatcherRegex = new RegExp(
137
137
  .map((l) => l.value)
138
138
  .join('|')})`
139
139
  );
140
+
141
+ export const getPosError = () => {
142
+ const error = JSON.parse(getCookie('pz-pos-error') ?? '{}');
143
+
144
+ // delete 'pz-pos-error' cookie when refreshing or closing page
145
+ window.addEventListener('beforeunload', () => {
146
+ removeCookie('pz-pos-error');
147
+ });
148
+
149
+ return error;
150
+ };