@akinon/pz-saved-card 1.67.0 → 1.68.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,13 +1,10 @@
1
1
  # @akinon/pz-saved-card
2
2
 
3
- ## 1.67.0
3
+ ## 1.68.0
4
4
 
5
- ## 1.66.0
5
+ ### Minor Changes
6
6
 
7
- ## 1.65.0
8
-
9
- ## 1.64.0
10
-
11
- ## 1.63.0
12
-
13
- ## 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.67.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;