@akinon/pz-saved-card 1.66.0 → 1.68.0

Sign up to get free protection for your applications and to get access to all the features.
package/CHANGELOG.md CHANGED
@@ -1,11 +1,10 @@
1
1
  # @akinon/pz-saved-card
2
2
 
3
- ## 1.66.0
3
+ ## 1.68.0
4
4
 
5
- ## 1.65.0
5
+ ### Minor Changes
6
6
 
7
- ## 1.64.0
8
-
9
- ## 1.63.0
10
-
11
- ## 1.62.0
7
+ - b92001c: ZERO-2903: fix missing dependency in useEffect hook
8
+ - 63597bc: ZERO-2903: update saved card readme
9
+ - 8fb37c4: ZERO-2903: enchance SavedCardOption component with custom wrapper props and classnames
10
+ - ce25dac: ZERO-2903: optimize saved card
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,20 +1,22 @@
1
1
  {
2
2
  "name": "@akinon/pz-saved-card",
3
- "version": "1.66.0",
3
+ "version": "1.68.0",
4
4
  "license": "MIT",
5
5
  "main": "src/index.tsx",
6
6
  "peerDependencies": {
7
7
  "react": "^18.0.0",
8
8
  "react-dom": "^18.0.0"
9
9
  },
10
+ "dependencies": {
11
+ "react-redux": "8.1.3"
12
+ },
10
13
  "devDependencies": {
11
14
  "@types/node": "^18.7.8",
12
15
  "@types/react": "^18.0.17",
13
16
  "@types/react-dom": "^18.0.6",
17
+ "prettier": "^3.0.3",
14
18
  "react": "^18.2.0",
15
19
  "react-dom": "^18.2.0",
16
- "typescript": "^4.7.4",
17
- "react-hook-form": "7.31.3",
18
- "@hookform/resolvers": "2.9.0"
20
+ "typescript": "^5.2.2"
19
21
  }
20
22
  }
package/readme.md CHANGED
@@ -1,36 +1,45 @@
1
- # pz-saved-card
1
+ # Saved Card Plugin
2
2
 
3
- ### Example Usage
4
- ##### File Path: src/views/checkout/steps/payment/options/saved-card.tsx
3
+ ## Installation
5
4
 
6
- ```javascript
7
- import PluginModule, { Component } from '@akinon/next/components/plugin-module';
8
- import CheckoutAgreements from '@theme/views/checkout/steps/payment/agreements';
9
-
10
- export default function SavedCard() {
11
- return (
12
- <PluginModule
13
- component={Component.SavedCard}
14
- props={{
15
- agreementCheckbox: (
16
- <CheckoutAgreements control={null} fieldId="agreement" error={null} />
17
- )
18
- }}
19
- />
20
- );
21
- }
5
+ There are two ways to install the Saved Card plugin:
6
+
7
+ ### 1. Install the npm package using Yarn
22
8
 
9
+ For the latest version, you can install the package using Yarn:
10
+
11
+ ```bash
12
+ yarn add @akinon/pz-saved-card
23
13
  ```
24
14
 
25
- ##### File Path: src/app/[commerce]/[locale]/[currency]/orders/saved-card-redirect/page.tsx
15
+ ### 2. Preferred installation method
26
16
 
27
- ```javascript
28
- import { SavedCardRedirect } from 'pz-saved-card/src/routes/saved-card-redirect';
17
+ You can also use the following command to install the extension with the latest plugins:
29
18
 
30
- export default SavedCardRedirect;
19
+ ```bash
20
+ npx @akinon/projectzero@latest --plugins
31
21
  ```
32
22
 
33
- ### Props
34
- | Properties | Type | Description |
35
- |----------------------|--------|--------------------------------------------|
36
- | texts | object | The translations of the component. |
23
+ ## Usage
24
+
25
+ ##### File Path: src/views/checkout/steps/payment/options/saved-card.tsx
26
+
27
+ ```jsx
28
+ import PluginModule, { Component } from '@akinon/next/components/plugin-module';
29
+
30
+ const SavedCard = () => {
31
+ return (
32
+ <PluginModule
33
+ component={Component.SavedCard}
34
+ props={{
35
+ texts: {
36
+ title: 'Pay with Saved Card',
37
+ button: 'Pay Now'
38
+ }
39
+ }}
40
+ />
41
+ );
42
+ };
43
+
44
+ export default SavedCard;
45
+ ```
@@ -0,0 +1,25 @@
1
+ import React, { cloneElement } from 'react';
2
+ import { Button, Icon } from '@akinon/next/components';
3
+
4
+ export const AgreementAndSubmit = ({
5
+ agreementCheckbox,
6
+ control,
7
+ errors,
8
+ buttonText
9
+ }) => (
10
+ <div className="flex flex-col text-xs pb-4 px-4 sm:px-6">
11
+ {agreementCheckbox &&
12
+ cloneElement(agreementCheckbox, {
13
+ control,
14
+ error: errors.agreement,
15
+ fieldId: 'agreement'
16
+ })}
17
+ <Button
18
+ type="submit"
19
+ className="group uppercase mt-4 inline-flex items-center justify-center"
20
+ >
21
+ <span>{buttonText}</span>
22
+ <Icon name="chevron-end" size={12} className="ml-2 h-3" />
23
+ </Button>
24
+ </div>
25
+ );
@@ -0,0 +1,17 @@
1
+ import { getCreditCardType } from '../utils';
2
+ import React from 'react';
3
+ import { cardImages } from '../views/saved-card-option';
4
+ import { Image } from '@akinon/next/components';
5
+
6
+ export const CardLabel = ({ card }) => (
7
+ <label className="flex flex-col w-full cursor-pointer md:flex-row md:items-center md:justify-between">
8
+ <p className="w-full text-[10px] lg:w-1/3">{card.masked_card_number}</p>
9
+ <Image
10
+ className="w-8 h-6 object-contain flex items-center justify-center"
11
+ width={50}
12
+ height={50}
13
+ src={cardImages[getCreditCardType(card.masked_card_number)].src}
14
+ alt={card.name}
15
+ />
16
+ </label>
17
+ );
@@ -0,0 +1,51 @@
1
+ import { setDeletionModalId, setDeletionModalVisible } from '../redux/reducer';
2
+ import React from 'react';
3
+ import { ErrorText } from './error-text';
4
+ import { CardLabel } from './card-label';
5
+ import { DeleteIcon } from './delete-icon';
6
+
7
+ export const CardSelectionSection = ({
8
+ title,
9
+ cards,
10
+ selectedCard,
11
+ onSelect,
12
+ register,
13
+ errors,
14
+ dispatch
15
+ }) => (
16
+ <div className="border-solid border-gray-400 px-4 py-2">
17
+ <span className="text-black-800 text-lg font-medium">{title}</span>
18
+ <ul className="mt-4 text-xs w-full">
19
+ {cards?.map((card) => (
20
+ <li
21
+ key={card.token}
22
+ className="p-4 mb-2 border-2 border-gray-200 flex justify-between items-center cursor-pointer"
23
+ onClick={(e) => {
24
+ e.preventDefault();
25
+ onSelect(card);
26
+ }}
27
+ >
28
+ <input
29
+ name="card"
30
+ type="radio"
31
+ checked={selectedCard?.token === card.token}
32
+ value={card.token}
33
+ id={card.token}
34
+ className="mr-2"
35
+ onChange={() => {}}
36
+ {...register('card')}
37
+ />
38
+ <CardLabel card={card} />
39
+ <DeleteIcon
40
+ onClick={(e) => {
41
+ e.stopPropagation();
42
+ dispatch(setDeletionModalId(card.id));
43
+ dispatch(setDeletionModalVisible(true));
44
+ }}
45
+ />
46
+ </li>
47
+ ))}
48
+ </ul>
49
+ {errors.card && <ErrorText message={errors.card?.message} />}
50
+ </div>
51
+ );
@@ -0,0 +1,80 @@
1
+ 'use client';
2
+
3
+ import { Button, LoaderSpinner, Modal } from '@akinon/next/components';
4
+ import { useAppDispatch, useAppSelector } from '@akinon/next/redux/hooks';
5
+ import {
6
+ setCards,
7
+ setDeletionModalId,
8
+ setDeletionModalVisible
9
+ } from '../redux/reducer';
10
+ import { useDeleteSavedCardMutation } from '../redux/api';
11
+ import { useState } from 'react';
12
+ import { DeletePopupTexts } from '../types';
13
+
14
+ const defaultTranslations = {
15
+ title: 'Are you sure you want to delete this card?',
16
+ delete_button: 'Delete',
17
+ cancel_button: 'Cancel'
18
+ };
19
+
20
+ export interface DeleteConfirmationModalProps {
21
+ translations?: DeletePopupTexts;
22
+ }
23
+
24
+ export const DeleteConfirmationModal = ({
25
+ translations
26
+ }: DeleteConfirmationModalProps) => {
27
+ const [error, setError] = useState<string | null>(null);
28
+ const { cards, deletion } = useAppSelector((state) => state.savedCard);
29
+ const dispatch = useAppDispatch();
30
+
31
+ const [deleteCard, { isLoading }] = useDeleteSavedCardMutation();
32
+
33
+ const handleClose = () => {
34
+ dispatch(setDeletionModalId(null));
35
+ dispatch(setDeletionModalVisible(false));
36
+ setError(null);
37
+ };
38
+
39
+ const handleDelete = async () => {
40
+ try {
41
+ await deleteCard(deletion.id).unwrap();
42
+ dispatch(setCards(cards.filter((card) => card.id !== deletion.id)));
43
+ handleClose();
44
+ } catch (error) {
45
+ setError(error.data.detail);
46
+ }
47
+ };
48
+
49
+ return (
50
+ <Modal
51
+ portalId="saved-card-remove-card-modal"
52
+ className="w-full sm:w-[28rem] max-h-[90vh] overflow-y-auto"
53
+ open={deletion.isModalVisible}
54
+ setOpen={() => dispatch(setDeletionModalVisible(false))}
55
+ >
56
+ <div className="px-6">
57
+ <h3 className="text-center mt-4 text-lg">
58
+ {translations?.title ?? defaultTranslations.title}
59
+ </h3>
60
+ <div className="flex flex-col gap-3 p-5 w-3/4 m-auto">
61
+ <Button className="py-3 h-auto" onClick={handleDelete}>
62
+ {isLoading ? (
63
+ <LoaderSpinner className="w-4 h-4" />
64
+ ) : (
65
+ translations?.delete_button ?? defaultTranslations.delete_button
66
+ )}
67
+ </Button>
68
+ <Button
69
+ appearance="outlined"
70
+ className="underline px-5 py-3 h-auto"
71
+ onClick={handleClose}
72
+ >
73
+ {translations?.cancel_button ?? defaultTranslations.cancel_button}
74
+ </Button>
75
+ {error && <p className="text-error text-xs text-center">{error}</p>}
76
+ </div>
77
+ </div>
78
+ </Modal>
79
+ );
80
+ };
@@ -0,0 +1,8 @@
1
+ import { Icon } from '@akinon/next/components';
2
+ import React from 'react';
3
+
4
+ export const DeleteIcon = ({ onClick }) => (
5
+ <span className="cursor-pointer p-1" onClick={onClick}>
6
+ <Icon name="close" size={12} />
7
+ </span>
8
+ );
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+
3
+ export const ErrorText = ({ message }) => (
4
+ <div className="w-full text-xs text-start px-1 mt-3 text-error">
5
+ {message}
6
+ </div>
7
+ );
@@ -0,0 +1,22 @@
1
+ import SavedCardInstallments from './installments';
2
+ import React from 'react';
3
+
4
+ export const InstallmentSection = ({
5
+ title,
6
+ selectedCard,
7
+ installmentOptions,
8
+ translations,
9
+ errors
10
+ }) => (
11
+ <div className="border-solid border-gray-400 bg-white">
12
+ <div className="px-4 py-2">
13
+ <span className="text-black-800 text-lg font-medium">{title}</span>
14
+ </div>
15
+ <SavedCardInstallments
16
+ selectedCard={selectedCard}
17
+ installmentOptions={installmentOptions}
18
+ translations={translations}
19
+ error={errors}
20
+ />
21
+ </div>
22
+ );
@@ -0,0 +1,113 @@
1
+ import { Price, Radio } from '@akinon/next/components';
2
+ import { useLocalization } from '@akinon/next/hooks';
3
+ import { useSetSavedCardInstallmentOptionMutation } from '../redux/api';
4
+ import { useEffect, useState } from 'react';
5
+ import { SavedCard } from '../redux/reducer';
6
+ import { InstallmentTexts } from '../types';
7
+
8
+ const defaultTranslations = {
9
+ payments: 'Payments',
10
+ per_month: 'Per Month',
11
+ total: 'Total'
12
+ };
13
+
14
+ type SavedCardInstallmentsProps = {
15
+ selectedCard: SavedCard;
16
+ installmentOptions: any[];
17
+ translations?: InstallmentTexts;
18
+ error: any;
19
+ };
20
+
21
+ const SavedCardInstallments = ({
22
+ selectedCard,
23
+ installmentOptions = [],
24
+ translations,
25
+ error
26
+ }: SavedCardInstallmentsProps) => {
27
+ const { t } = useLocalization();
28
+ const [installmentOption, setInstallmentOption] = useState(null);
29
+ const [setInstallment] = useSetSavedCardInstallmentOptionMutation();
30
+
31
+ useEffect(() => {
32
+ if (
33
+ selectedCard &&
34
+ installmentOptions.length > 0 &&
35
+ installmentOptions[0]?.pk !== installmentOption
36
+ ) {
37
+ const firstOptionPk = installmentOptions[0].pk;
38
+ setInstallment({
39
+ installment: firstOptionPk
40
+ });
41
+ setInstallmentOption(firstOptionPk);
42
+ }
43
+ }, [installmentOptions, installmentOption, setInstallment, selectedCard]);
44
+
45
+ if (installmentOptions.length === 0) {
46
+ return (
47
+ <div className="text-xs text-black-800 p-4 sm:p-6">
48
+ {t('checkout.payment.installment_options.description')}
49
+ </div>
50
+ );
51
+ }
52
+
53
+ return (
54
+ <div>
55
+ <div className="px-4 mb-4 sm:px-6 sm:mb-6">
56
+ <table className="w-full border border-solid border-gray-400">
57
+ <thead>
58
+ <tr>
59
+ <th className="text-xs font-normal border-e border-solid border-gray-400 px-2 py-2 text-start">
60
+ {translations?.payments || defaultTranslations.payments}
61
+ </th>
62
+ <th className="text-xs font-normal border-e border-solid border-gray-400 px-2 py-2 text-right">
63
+ {translations?.per_month || defaultTranslations.per_month}
64
+ </th>
65
+ <th className="text-xs font-normal border-e border-solid border-gray-400 px-2 py-2 text-right">
66
+ {translations?.total || defaultTranslations.total}
67
+ </th>
68
+ </tr>
69
+ </thead>
70
+ <tbody>
71
+ {installmentOptions.map((option) => (
72
+ <tr
73
+ key={`installment-${option.pk}`}
74
+ className="border-t border-solid border-gray-400"
75
+ >
76
+ <td className="text-xs font-normal border-e border-solid border-gray-400 px-2 py-2 text-left">
77
+ <Radio
78
+ value={option.pk}
79
+ name="installment"
80
+ checked={option.pk === installmentOption}
81
+ onChange={() => {
82
+ setInstallment({
83
+ installment: option.pk
84
+ });
85
+ setInstallmentOption(option.pk);
86
+ }}
87
+ >
88
+ <span className="w-full flex items-center justify-start pl-2">
89
+ <span className="text-xs text-black-800 transition-all">
90
+ {option.label}
91
+ </span>
92
+ </span>
93
+ </Radio>
94
+ </td>
95
+ <td className="text-xs font-normal border-e border-solid border-gray-400 px-2 py-2 text-right">
96
+ <Price value={option.monthly_price_with_accrued_interest} />
97
+ </td>
98
+ <td className="text-xs font-normal border-e border-solid border-gray-400 px-2 py-2 text-right">
99
+ <Price value={option.price_with_accrued_interest} />
100
+ </td>
101
+ </tr>
102
+ ))}
103
+ </tbody>
104
+ </table>
105
+ </div>
106
+ {error && (
107
+ <div className="px-6 mt-4 text-sm text-error">{error.message}</div>
108
+ )}
109
+ </div>
110
+ );
111
+ };
112
+
113
+ export default SavedCardInstallments;
package/src/index.tsx CHANGED
@@ -1,6 +1,4 @@
1
- export * from './views/option';
2
-
3
1
  import savedCardReducer from './redux/reducer';
4
- import savedCardMiddleware from './redux/middleware';
2
+ import SavedCardOption from './views/saved-card-option';
5
3
 
6
- export { savedCardReducer, savedCardMiddleware };
4
+ export { savedCardReducer, SavedCardOption };
@@ -2,6 +2,7 @@ import { CheckoutContext, PreOrder } from '@akinon/next/types';
2
2
  import { api } from '@akinon/next/data/client/api';
3
3
  import { buildClientRequestUrl } from '@akinon/next/utils';
4
4
  import { setPaymentStepBusy } from '@akinon/next/redux/reducers/checkout';
5
+ import { SavedCard } from './reducer';
5
6
 
6
7
  interface CheckoutResponse {
7
8
  pre_order?: PreOrder;
@@ -13,21 +14,55 @@ interface CheckoutResponse {
13
14
  redirect_url?: string;
14
15
  }
15
16
 
16
- type CompleteSavedCardRequest =
17
- | { ucs_token: string; consumer_token: string; card_token: string }
18
- | {
19
- agreement: boolean;
20
- card_number: string;
21
- card_cvv: string;
22
- card_month: string;
23
- card_year: string;
24
- register_consumer_card: boolean;
25
- };
17
+ interface GetResponse<T> {
18
+ count: number;
19
+ next: null;
20
+ previous: null;
21
+ results: T[];
22
+ }
23
+
24
+ type SetSavedCardRequest = {
25
+ card: SavedCard;
26
+ };
27
+
28
+ type DeleteSavedCardRequest = number;
29
+
30
+ type SetInstallmentRequest = {
31
+ installment: string;
32
+ };
33
+
34
+ type CompleteSavedCardRequest = {
35
+ agreement: boolean;
36
+ };
26
37
 
27
38
  export const savedCardApi = api.injectEndpoints({
28
39
  endpoints: (build) => ({
29
- setSavedCard: build.mutation<CheckoutResponse, string>({
30
- query: (ucsToken) => ({
40
+ getSavedCards: build.query<GetResponse<SavedCard>, void>({
41
+ query: () => ({
42
+ url: buildClientRequestUrl('/users/saved-cards/')
43
+ }),
44
+ async onQueryStarted(arg, { dispatch, queryFulfilled }) {
45
+ dispatch(setPaymentStepBusy(true));
46
+ await queryFulfilled;
47
+ dispatch(setPaymentStepBusy(false));
48
+ }
49
+ }),
50
+ deleteSavedCard: build.mutation<
51
+ GetResponse<SavedCard>,
52
+ DeleteSavedCardRequest
53
+ >({
54
+ query: (cardId) => ({
55
+ url: buildClientRequestUrl(`/users/saved-cards/${cardId}`),
56
+ method: 'DELETE'
57
+ }),
58
+ async onQueryStarted(arg, { dispatch, queryFulfilled }) {
59
+ dispatch(setPaymentStepBusy(true));
60
+ await queryFulfilled;
61
+ dispatch(setPaymentStepBusy(false));
62
+ }
63
+ }),
64
+ setSavedCard: build.mutation<CheckoutResponse, SetSavedCardRequest>({
65
+ query: ({ card }) => ({
31
66
  url: buildClientRequestUrl(
32
67
  `/orders/checkout?page=SavedCardSelectionPage`,
33
68
  {
@@ -36,17 +71,20 @@ export const savedCardApi = api.injectEndpoints({
36
71
  ),
37
72
  method: 'POST',
38
73
  body: {
39
- card: ucsToken
74
+ card: card.token
40
75
  }
41
76
  }),
42
- async onQueryStarted(arg, { dispatch, queryFulfilled }) {
77
+ async onQueryStarted({ card }, { dispatch, queryFulfilled }) {
43
78
  dispatch(setPaymentStepBusy(true));
44
79
  await queryFulfilled;
45
80
  dispatch(setPaymentStepBusy(false));
46
81
  }
47
82
  }),
48
- setInstallment: build.mutation<CheckoutResponse, number>({
49
- query: (pk) => ({
83
+ setSavedCardInstallmentOption: build.mutation<
84
+ CheckoutResponse,
85
+ SetInstallmentRequest
86
+ >({
87
+ query: ({ installment }) => ({
50
88
  url: buildClientRequestUrl(
51
89
  `/orders/checkout?page=SavedCardInstallmentSelectionPage`,
52
90
  {
@@ -55,7 +93,7 @@ export const savedCardApi = api.injectEndpoints({
55
93
  ),
56
94
  method: 'POST',
57
95
  body: {
58
- installment: pk
96
+ installment
59
97
  }
60
98
  }),
61
99
  async onQueryStarted(arg, { dispatch, queryFulfilled }) {
@@ -64,19 +102,21 @@ export const savedCardApi = api.injectEndpoints({
64
102
  dispatch(setPaymentStepBusy(false));
65
103
  }
66
104
  }),
67
- setCompleteSavedCard: build.mutation<
105
+ completeSavedCard: build.mutation<
68
106
  CheckoutResponse,
69
107
  CompleteSavedCardRequest
70
108
  >({
71
- query: (data) => ({
109
+ query: ({ agreement }) => ({
72
110
  url: buildClientRequestUrl(
73
- `/orders/checkout?page=SavedCardConfirmationPage`,
111
+ `/orders/checkout?page=CompleteSavedCardRequest`,
74
112
  {
75
113
  useFormData: true
76
114
  }
77
115
  ),
78
116
  method: 'POST',
79
- body: data
117
+ body: {
118
+ agreement
119
+ }
80
120
  }),
81
121
  async onQueryStarted(arg, { dispatch, queryFulfilled }) {
82
122
  dispatch(setPaymentStepBusy(true));
@@ -88,7 +128,9 @@ export const savedCardApi = api.injectEndpoints({
88
128
  });
89
129
 
90
130
  export const {
131
+ useGetSavedCardsQuery,
91
132
  useSetSavedCardMutation,
92
- useSetInstallmentMutation,
93
- useSetCompleteSavedCardMutation
133
+ useSetSavedCardInstallmentOptionMutation,
134
+ useCompleteSavedCardMutation,
135
+ useDeleteSavedCardMutation
94
136
  } = savedCardApi;
@@ -1,33 +1,45 @@
1
- 'use client';
2
-
3
1
  import { createSlice } from '@reduxjs/toolkit';
4
2
 
3
+ export type SavedCard = {
4
+ id: number;
5
+ name: string;
6
+ masked_card_number: string;
7
+ token: string;
8
+ };
9
+
5
10
  export interface SavedCardState {
6
- ucs: {
7
- maskedGsmNumber?: string;
8
- script?: string;
9
- scriptType?: string;
10
- ucsToken?: string;
11
+ cards?: Array<SavedCard>;
12
+ deletion: {
13
+ id: number;
14
+ isModalVisible: boolean;
11
15
  };
12
16
  }
13
17
 
14
18
  const initialState: SavedCardState = {
15
- ucs: {}
19
+ cards: undefined,
20
+ deletion: {
21
+ id: null,
22
+ isModalVisible: false
23
+ }
16
24
  };
17
25
 
18
26
  const savedCardSlice = createSlice({
19
27
  name: 'savedCard',
20
28
  initialState,
21
29
  reducers: {
22
- setUcs(state, action) {
23
- state.ucs = action.payload;
30
+ setCards(state, { payload }) {
31
+ state.cards = payload;
32
+ },
33
+ setDeletionModalId(state, { payload }) {
34
+ state.deletion.id = payload;
24
35
  },
25
- resetUcs() {
26
- return initialState;
36
+ setDeletionModalVisible(state, { payload }) {
37
+ state.deletion.isModalVisible = payload;
27
38
  }
28
39
  }
29
40
  });
30
41
 
31
- export const { setUcs, resetUcs } = savedCardSlice.actions;
42
+ export const { setCards, setDeletionModalId, setDeletionModalVisible } =
43
+ savedCardSlice.actions;
32
44
 
33
45
  export default savedCardSlice.reducer;