@akinon/pz-saved-card 2.0.0-beta.9 → 2.0.1

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,45 +1,153 @@
1
1
  # @akinon/pz-saved-card
2
2
 
3
- ## 2.0.0-beta.9
3
+ ## 2.0.1
4
+
5
+ ## 2.0.0
6
+
7
+ ## 2.0.0-beta.27
8
+
9
+ ## 2.0.0-beta.26
10
+
11
+ ## 2.0.0-beta.25
12
+
13
+ ## 2.0.0-beta.24
14
+
15
+ ## 2.0.0-beta.23
16
+
17
+ ## 2.0.0-beta.22
18
+
19
+ ## 2.0.0-beta.21
20
+
21
+ ## 2.0.0-beta.20
22
+
23
+ ## 1.126.0
24
+
25
+ ## 1.125.2
26
+
27
+ ## 1.125.1
28
+
29
+ ## 1.125.0
30
+
31
+ ## 1.124.0
32
+
33
+ ## 1.123.0
34
+
35
+ ## 1.122.0
36
+
37
+ ## 1.121.0
38
+
39
+ ## 1.120.0
40
+
41
+ ### Minor Changes
42
+
43
+ - 6ad72e8d: ZERO-4032: Add loading state management for payment submissions across multiple components and add safe guarding
44
+
45
+ ## 1.119.0
46
+
47
+ ## 1.118.0
48
+
49
+ ## 1.117.0
50
+
51
+ ## 1.116.0
52
+
53
+ ## 1.115.0
54
+
55
+ ## 1.114.0
56
+
57
+ ## 1.113.0
58
+
59
+ ## 1.112.0
60
+
61
+ ## 1.111.0
62
+
63
+ ## 1.110.0
64
+
65
+ ## 1.109.0
66
+
67
+ ## 1.108.0
68
+
69
+ ## 1.107.0
4
70
 
5
71
  ### Minor Changes
6
72
 
7
- - 0fe7711: ZERO-3387: Upgrade nextjs, eslint-config-next
73
+ - 4ca44c78: ZERO-3634: add register_consumer_card
74
+ - 6bfbdc27: ZERO-3653: optimize saved card option
75
+ - 5b500797: ZERO-3634: iyzico saved card
76
+ - 9442cf01: ZERO-3653: make the register_consumer_card option optional
8
77
 
9
- ## 2.0.0-beta.8
78
+ ## 1.106.0
10
79
 
11
- ## 2.0.0-beta.7
80
+ ## 1.105.0
12
81
 
13
- ## 2.0.0-beta.6
82
+ ## 1.104.0
83
+
84
+ ## 1.103.0
85
+
86
+ ## 1.102.0
87
+
88
+ ## 1.101.0
89
+
90
+ ## 1.100.0
91
+
92
+ ## 1.99.0
14
93
 
15
94
  ### Minor Changes
16
95
 
17
- - 8f05f9b: ZERO-3250: Beta branch synchronized with Main branch
96
+ - d58538b: ZERO-3638: Enhance RC pipeline: add fetch, merge, and pre-release setup with conditional commit
97
+
98
+ ## 1.98.0
99
+
100
+ ## 1.97.0
18
101
 
19
- ## 2.0.0-beta.5
102
+ ## 1.96.0
20
103
 
21
- ## 2.0.0-beta.4
104
+ ## 1.95.0
22
105
 
23
- ## 2.0.0-beta.3
106
+ ## 1.94.0
24
107
 
25
- ## 2.0.0-beta.2
108
+ ## 1.93.0
109
+
110
+ ## 1.92.0
111
+
112
+ ## 1.91.0
113
+
114
+ ## 1.90.0
115
+
116
+ ## 1.89.0
26
117
 
27
118
  ### Minor Changes
28
119
 
29
- - a006015: ZERO-3116: Add not-found page and update default middleware.
30
- - 1eeb3d8: ZERO-3116: Add not found page
120
+ - e2026ec: ZERO-3353: add missing test ids for e2e test
121
+
122
+ ## 1.88.0
123
+
124
+ ## 1.87.0
125
+
126
+ ## 1.86.0
31
127
 
32
- ## 2.0.0-beta.1
128
+ ## 1.85.0
129
+
130
+ ## 1.84.0
33
131
 
34
132
  ### Minor Changes
35
133
 
36
- - ZERO-3091: Upgrade Next.js to v15 and React to v19
134
+ - 624a4eb: ZERO-3276: Update installation instructions across multiple README files to standardize format and improve clarity
135
+
136
+ ## 1.83.0
137
+
138
+ ## 1.82.0
139
+
140
+ ## 1.81.0
141
+
142
+ ## 1.80.0
143
+
144
+ ## 1.79.0
37
145
 
38
- ## 2.0.0-beta.0
146
+ ## 1.78.0
39
147
 
40
- ### Major Changes
148
+ ## 1.77.0
41
149
 
42
- - be6c09d: ZERO-3114: Create beta version.
150
+ ## 1.76.0
43
151
 
44
152
  ## 1.75.0
45
153
 
package/package.json CHANGED
@@ -1,22 +1,22 @@
1
1
  {
2
2
  "name": "@akinon/pz-saved-card",
3
- "version": "2.0.0-beta.9",
3
+ "version": "2.0.1",
4
4
  "license": "MIT",
5
5
  "main": "src/index.tsx",
6
6
  "peerDependencies": {
7
- "react": "^19.0.0",
8
- "react-dom": "^19.0.0"
7
+ "react": "^18.0.0 || ^19.0.0",
8
+ "react-dom": "^18.0.0 || ^19.0.0"
9
9
  },
10
10
  "dependencies": {
11
11
  "react-redux": "8.1.3"
12
12
  },
13
13
  "devDependencies": {
14
- "@types/node": "^22.10.2",
15
- "@types/react": "^19.0.2",
16
- "@types/react-dom": "^19.0.2",
17
- "prettier": "^3.4.2",
18
- "react": "^19.0.0",
19
- "react-dom": "^19.0.0",
20
- "typescript": "^5.7.2"
14
+ "@types/node": "^18.7.8",
15
+ "@types/react": "^18.0.17",
16
+ "@types/react-dom": "^18.0.6",
17
+ "prettier": "^3.0.3",
18
+ "react": "19.2.5",
19
+ "react-dom": "19.2.5",
20
+ "typescript": "^5.2.2"
21
21
  }
22
22
  }
@@ -1,22 +1,37 @@
1
- import React, { cloneElement } from 'react';
1
+ import { cloneElement } from 'react';
2
2
  import { Button, Icon } from '@akinon/next/components';
3
+ import { AgreementAndSubmitProps } from '../types';
3
4
 
4
5
  export const AgreementAndSubmit = ({
5
6
  agreementCheckbox,
6
7
  control,
7
8
  errors,
8
- buttonText
9
- }) => (
9
+ buttonText,
10
+ formError,
11
+ isSubmitting
12
+ }: AgreementAndSubmitProps) => (
10
13
  <div className="flex flex-col text-xs pb-4 px-4 sm:px-6">
11
14
  {agreementCheckbox &&
12
- cloneElement(agreementCheckbox, {
13
- control,
15
+ cloneElement(agreementCheckbox as any, {
16
+ control: control as any,
14
17
  error: errors.agreement,
15
18
  fieldId: 'agreement'
16
19
  })}
20
+ {formError?.non_field_errors && (
21
+ <div className="w-full text-xs text-start px-1 mt-3 text-error">
22
+ {formError.non_field_errors}
23
+ </div>
24
+ )}
25
+ {formError?.status && (
26
+ <div className="w-full text-xs text-start px-1 mt-3 text-error">
27
+ {formError.status}
28
+ </div>
29
+ )}
17
30
  <Button
18
31
  type="submit"
19
32
  className="group uppercase mt-4 inline-flex items-center justify-center"
33
+ disabled={isSubmitting}
34
+ data-testid="saved-card-submit-button"
20
35
  >
21
36
  <span>{buttonText}</span>
22
37
  <Icon name="chevron-end" size={12} className="ml-2 h-3" />
@@ -0,0 +1,127 @@
1
+ import React from 'react';
2
+ import { Input, Select, Icon, Image } from '@akinon/next/components';
3
+ import { Control, UseFormRegister, FieldErrors } from 'react-hook-form';
4
+
5
+ export interface CardFormSectionProps {
6
+ register: UseFormRegister<any>;
7
+ control: Control<any>;
8
+ errors: FieldErrors;
9
+ months: Array<{ label: string; value: string }>;
10
+ years: Array<{ label: string; value: string }>;
11
+ translations?: {
12
+ cardHolder?: string;
13
+ cardNumber?: string;
14
+ expiryDate?: string;
15
+ cvv?: string;
16
+ cvvTooltip?: string;
17
+ saveCardForFuture?: string;
18
+ };
19
+ }
20
+
21
+ export const CardFormSection = ({
22
+ register,
23
+ control,
24
+ errors,
25
+ months,
26
+ years,
27
+ translations = {},
28
+ }: CardFormSectionProps) => {
29
+ return (
30
+ <div className="w-full bg-white">
31
+ <div className="px-4 my-2 w-full flex justify-between flex-wrap">
32
+ <div className="my-2 w-full sm:px-4">
33
+ <Input
34
+ label={translations.cardHolder}
35
+ {...register('card_holder')}
36
+ error={errors.card_holder}
37
+ />
38
+ </div>
39
+
40
+ <div className="my-2 w-full flex flex-col sm:px-4">
41
+ <div className="text-xs text-gray-800 mb-2 w-full flex justify-between items-center">
42
+ <span>{translations.cardNumber}</span>
43
+ </div>
44
+
45
+ <Input
46
+ format="#### #### #### ####"
47
+ mask="_"
48
+ allowEmptyFormatting={true}
49
+ control={control}
50
+ {...register('card_number')}
51
+ error={errors.card_number}
52
+ />
53
+ </div>
54
+
55
+ <div className="w-full my-2 sm:flex">
56
+ <div className="sm:w-2/3 sm:px-4">
57
+ <label
58
+ className="flex w-full text-xs text-start text-black-400 mb-1.5"
59
+ htmlFor="card_month"
60
+ >
61
+ {translations.expiryDate}
62
+ </label>
63
+
64
+ <div className="flex w-full h-10 space-x-2.5">
65
+ <div className="w-2/4">
66
+ <Select
67
+ className="w-full text-xs border-gray-400 sm:text-sm"
68
+ options={months}
69
+ {...register('card_month')}
70
+ error={errors.card_month}
71
+ />
72
+ </div>
73
+
74
+ <div className="w-2/4">
75
+ <Select
76
+ className="w-full text-xs border-gray-400 sm:text-sm"
77
+ options={years}
78
+ {...register('card_year')}
79
+ error={errors.card_year}
80
+ />
81
+ </div>
82
+ </div>
83
+ </div>
84
+
85
+ <div className="my-2 sm:w-1/3 sm:px-4 sm:my-0">
86
+ <label
87
+ className="flex w-full text-xs text-start text-black-400 mb-1.5"
88
+ htmlFor="card_cvv"
89
+ >
90
+ {translations.cvv}
91
+ </label>
92
+ <Input
93
+ format="###"
94
+ mask="_"
95
+ control={control}
96
+ allowEmptyFormatting={true}
97
+ {...register('card_cvv')}
98
+ error={errors.card_cvv}
99
+ />
100
+ <div className="group relative flex items-center justify-start text-gray-600 cursor-pointer mt-2 transition-all hover:text-secondary">
101
+ <span className="text-xs underline">
102
+ {translations.cvv}
103
+ </span>
104
+ <Icon name="cvc" size={16} className="leading-none ml-2" />
105
+ <div className="hidden group-hover:block absolute right-0 bottom-5 w-[11rem] lg:w-[21rem] lg:left-auto lg:right-auto border-2">
106
+ <Image src="/cvv.jpg" alt="Cvv" width={385} height={264} />
107
+ </div>
108
+ </div>
109
+ </div>
110
+ </div>
111
+
112
+ <div className="my-2 w-full flex flex-col sm:px-4">
113
+ <label className="flex items-center space-x-2">
114
+ <input
115
+ type="checkbox"
116
+ {...register('register_consumer_card')}
117
+ className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
118
+ />
119
+ <span className="text-sm text-gray-700">
120
+ {translations.saveCardForFuture}
121
+ </span>
122
+ </label>
123
+ </div>
124
+ </div>
125
+ </div>
126
+ );
127
+ };
@@ -1,5 +1,4 @@
1
1
  import { setDeletionModalId, setDeletionModalVisible } from '../redux/reducer';
2
- import React from 'react';
3
2
  import { ErrorText } from './error-text';
4
3
  import { CardLabel } from './card-label';
5
4
  import { DeleteIcon } from './delete-icon';
@@ -16,7 +15,7 @@ export const CardSelectionSection = ({
16
15
  <div className="border-solid border-gray-400 px-4 py-2">
17
16
  <span className="text-black-800 text-lg font-medium">{title}</span>
18
17
  <ul className="mt-4 text-xs w-full">
19
- {cards?.map((card) => (
18
+ {cards?.map((card, index) => (
20
19
  <li
21
20
  key={card.token}
22
21
  className="p-4 mb-2 border-2 border-gray-200 flex justify-between items-center cursor-pointer"
@@ -24,6 +23,7 @@ export const CardSelectionSection = ({
24
23
  e.preventDefault();
25
24
  onSelect(card);
26
25
  }}
26
+ data-testid={`saved-card-item-${index}`}
27
27
  >
28
28
  <input
29
29
  name="card"
@@ -1,19 +1,17 @@
1
1
  import SavedCardInstallments from './installments';
2
- import React from 'react';
2
+ import { InstallmentSectionProps } from '../types';
3
3
 
4
4
  export const InstallmentSection = ({
5
5
  title,
6
- selectedCard,
7
6
  installmentOptions,
8
7
  translations,
9
8
  errors
10
- }) => (
9
+ }: InstallmentSectionProps) => (
11
10
  <div className="border-solid border-gray-400 bg-white">
12
11
  <div className="px-4 py-2">
13
12
  <span className="text-black-800 text-lg font-medium">{title}</span>
14
13
  </div>
15
14
  <SavedCardInstallments
16
- selectedCard={selectedCard}
17
15
  installmentOptions={installmentOptions}
18
16
  translations={translations}
19
17
  error={errors}
@@ -12,14 +12,12 @@ const defaultTranslations = {
12
12
  };
13
13
 
14
14
  type SavedCardInstallmentsProps = {
15
- selectedCard: SavedCard;
16
15
  installmentOptions: any[];
17
16
  translations?: InstallmentTexts;
18
17
  error: any;
19
18
  };
20
19
 
21
20
  const SavedCardInstallments = ({
22
- selectedCard,
23
21
  installmentOptions = [],
24
22
  translations,
25
23
  error
@@ -30,7 +28,6 @@ const SavedCardInstallments = ({
30
28
 
31
29
  useEffect(() => {
32
30
  if (
33
- selectedCard &&
34
31
  installmentOptions.length > 0 &&
35
32
  installmentOptions[0]?.pk !== installmentOption
36
33
  ) {
@@ -40,7 +37,7 @@ const SavedCardInstallments = ({
40
37
  });
41
38
  setInstallmentOption(firstOptionPk);
42
39
  }
43
- }, [installmentOptions, installmentOption, setInstallment, selectedCard]);
40
+ }, [installmentOptions, installmentOption, setInstallment]);
44
41
 
45
42
  if (installmentOptions.length === 0) {
46
43
  return (
@@ -68,7 +65,7 @@ const SavedCardInstallments = ({
68
65
  </tr>
69
66
  </thead>
70
67
  <tbody>
71
- {installmentOptions.map((option) => (
68
+ {installmentOptions.map((option, index) => (
72
69
  <tr
73
70
  key={`installment-${option.pk}`}
74
71
  className="border-t border-solid border-gray-400"
@@ -84,6 +81,7 @@ const SavedCardInstallments = ({
84
81
  });
85
82
  setInstallmentOption(option.pk);
86
83
  }}
84
+ data-testid={`saved-card-installment-${index}`}
87
85
  >
88
86
  <span className="w-full flex items-center justify-start pl-2">
89
87
  <span className="text-xs text-black-800 transition-all">
@@ -104,7 +102,7 @@ const SavedCardInstallments = ({
104
102
  </table>
105
103
  </div>
106
104
  {error && (
107
- <div className="px-6 mt-4 text-sm text-error">{error.message}</div>
105
+ <div className="px-6 mt-4 text-sm text-error">{String(error.message)}</div>
108
106
  )}
109
107
  </div>
110
108
  );
package/src/index.tsx CHANGED
@@ -1,5 +1,11 @@
1
1
  import savedCardReducer from './redux/reducer';
2
2
  import savedCardMiddleware from './redux/middleware';
3
3
  import SavedCardOption from './views/saved-card-option';
4
+ import IyzicoSavedCardOption from './views/iyzico-saved-card-option';
4
5
 
5
- export { savedCardReducer, savedCardMiddleware, SavedCardOption };
6
+ export {
7
+ savedCardReducer,
8
+ savedCardMiddleware,
9
+ SavedCardOption,
10
+ IyzicoSavedCardOption
11
+ };
package/src/redux/api.ts CHANGED
@@ -21,20 +21,49 @@ interface GetResponse<T> {
21
21
  results: T[];
22
22
  }
23
23
 
24
- type SetSavedCardRequest = {
24
+ type DefaultSetSavedCardRequest = {
25
25
  card: SavedCard;
26
26
  };
27
27
 
28
+ type IyzicoSetSavedCardRequest = {
29
+ card: string;
30
+ };
31
+
32
+ type SetSavedCardRequest =
33
+ | DefaultSetSavedCardRequest
34
+ | IyzicoSetSavedCardRequest;
35
+
28
36
  type DeleteSavedCardRequest = number;
29
37
 
30
38
  type SetInstallmentRequest = {
31
39
  installment: string;
32
40
  };
33
41
 
34
- type CompleteSavedCardRequest = {
42
+ type DefaultCompleteSavedCardRequest = {
35
43
  agreement: boolean;
36
44
  };
37
45
 
46
+ type IyzicoCompleteSavedCardRequest =
47
+ | {
48
+ agreement: boolean;
49
+ register_consumer_card: boolean;
50
+ consumer_token: string;
51
+ card_token: string;
52
+ }
53
+ | {
54
+ agreement: boolean;
55
+ card_holder: string;
56
+ card_number: string;
57
+ card_cvv: string;
58
+ card_month: string;
59
+ card_year: string;
60
+ register_consumer_card: boolean;
61
+ };
62
+
63
+ type CompleteSavedCardRequest =
64
+ | DefaultCompleteSavedCardRequest
65
+ | IyzicoCompleteSavedCardRequest;
66
+
38
67
  export const savedCardApi = api.injectEndpoints({
39
68
  endpoints: (build) => ({
40
69
  getSavedCards: build.query<GetResponse<SavedCard>, void>({
@@ -71,7 +100,7 @@ export const savedCardApi = api.injectEndpoints({
71
100
  ),
72
101
  method: 'POST',
73
102
  body: {
74
- card: card.token
103
+ card: typeof card === 'string' ? card : card.token
75
104
  }
76
105
  }),
77
106
  async onQueryStarted({ card }, { dispatch, queryFulfilled }) {
@@ -106,7 +135,7 @@ export const savedCardApi = api.injectEndpoints({
106
135
  CheckoutResponse,
107
136
  CompleteSavedCardRequest
108
137
  >({
109
- query: ({ agreement }) => ({
138
+ query: (body) => ({
110
139
  url: buildClientRequestUrl(
111
140
  `/orders/checkout?page=CompleteSavedCardRequest`,
112
141
  {
@@ -114,9 +143,7 @@ export const savedCardApi = api.injectEndpoints({
114
143
  }
115
144
  ),
116
145
  method: 'POST',
117
- body: {
118
- agreement
119
- }
146
+ body
120
147
  }),
121
148
  async onQueryStarted(arg, { dispatch, queryFulfilled }) {
122
149
  dispatch(setPaymentStepBusy(true));
@@ -1,22 +1,28 @@
1
1
  import { Middleware } from '@reduxjs/toolkit';
2
- import { setCards } from './reducer';
2
+ import { CheckoutResult } from '@akinon/next/types';
3
+ import { setCards, setUcs } from './reducer';
3
4
 
4
5
  const savedCardMiddleware: Middleware = ({ dispatch }) => {
5
6
  return (next) => (action) => {
6
- const result = next(action);
7
+ const result = next(action) as CheckoutResult;
7
8
 
8
9
  const savedCardContext = result.payload?.context_list?.find(
9
- (context) =>
10
- context.page_slug === 'savedcardselectionpage' &&
11
- context.page_context.cards
10
+ (context) => context.page_slug === 'savedcardselectionpage'
12
11
  );
13
12
 
14
- if (savedCardContext) {
13
+ if (savedCardContext && savedCardContext.page_context?.cards) {
15
14
  const cards = JSON.parse(
16
15
  JSON.stringify(savedCardContext.page_context.cards)
17
16
  );
18
17
 
19
18
  dispatch(setCards(cards));
19
+ } else if (savedCardContext && savedCardContext.page_context?.ucs) {
20
+ const ucs = JSON.parse(JSON.stringify(savedCardContext.page_context.ucs));
21
+
22
+ const match = ucs.script.match(/<script\b[^>]*>([\s\S]*?)<\/script>/);
23
+ ucs.script = match ? match[1] : ucs.script;
24
+
25
+ dispatch(setUcs(ucs));
20
26
  }
21
27
 
22
28
  return result;
@@ -7,12 +7,20 @@ export type SavedCard = {
7
7
  token: string;
8
8
  };
9
9
 
10
+ export type IyzicoUcs = {
11
+ maskedGsmNumber?: string;
12
+ script?: string;
13
+ scriptType?: string;
14
+ ucsToken?: string;
15
+ };
16
+
10
17
  export interface SavedCardState {
11
18
  cards?: Array<SavedCard>;
12
19
  deletion: {
13
20
  id: number;
14
21
  isModalVisible: boolean;
15
22
  };
23
+ ucs?: IyzicoUcs;
16
24
  }
17
25
 
18
26
  const initialState: SavedCardState = {
@@ -35,11 +43,22 @@ const savedCardSlice = createSlice({
35
43
  },
36
44
  setDeletionModalVisible(state, { payload }) {
37
45
  state.deletion.isModalVisible = payload;
46
+ },
47
+ setUcs(state, action: { payload: IyzicoUcs }) {
48
+ state.ucs = action.payload;
49
+ },
50
+ resetUcs() {
51
+ return initialState;
38
52
  }
39
53
  }
40
54
  });
41
55
 
42
- export const { setCards, setDeletionModalId, setDeletionModalVisible } =
43
- savedCardSlice.actions;
56
+ export const {
57
+ setCards,
58
+ setDeletionModalId,
59
+ setDeletionModalVisible,
60
+ setUcs,
61
+ resetUcs
62
+ } = savedCardSlice.actions;
44
63
 
45
64
  export default savedCardSlice.reducer;
@@ -1,4 +1,5 @@
1
1
  import { ReactElement } from 'react';
2
+ import { Control, FieldErrors } from 'react-hook-form';
2
3
 
3
4
  export type InstallmentTexts = {
4
5
  title?: string;
@@ -15,6 +16,9 @@ export type DeletePopupTexts = {
15
16
 
16
17
  export type ErrorTexts = {
17
18
  required?: string;
19
+ cardNumberLength?: string;
20
+ cvvLength?: string;
21
+ cardHolderMatches?: string;
18
22
  };
19
23
 
20
24
  export type SavedCardOptionTexts = {
@@ -25,6 +29,29 @@ export type SavedCardOptionTexts = {
25
29
  errors?: ErrorTexts;
26
30
  };
27
31
 
32
+ export type IyzicoSavedCardOptionTexts = {
33
+ title?: string;
34
+ button?: string;
35
+ installment?: InstallmentTexts;
36
+ errors?: ErrorTexts & {
37
+ saveCardRequired?: string;
38
+ };
39
+ label?: {
40
+ cardHolder?: string;
41
+ cardNumber?: string;
42
+ expiryDate?: string;
43
+ cvv?: string;
44
+ saveCardForFuture?: string;
45
+ };
46
+ placeholder?: {
47
+ cardHolder?: string;
48
+ cardMonth?: string;
49
+ cardYear?: string;
50
+ };
51
+ savedCardsTitle?: string;
52
+ paymentErrorMessage?: string;
53
+ };
54
+
28
55
  export type CardSelectionSectionProps = {
29
56
  title: string;
30
57
  cards: any[];
@@ -37,15 +64,35 @@ export type CardSelectionSectionProps = {
37
64
 
38
65
  export type InstallmentSectionProps = {
39
66
  title: string;
40
- selectedCard: any;
41
67
  installmentOptions: any;
42
68
  translations: InstallmentTexts;
43
69
  errors: any;
70
+ setFormValue?: any;
44
71
  };
45
72
 
46
- export type AgreementAndSubmitProps = {
47
- agreementCheckbox: ReactElement | undefined;
73
+ export interface AgreementAndSubmitProps {
74
+ agreementCheckbox?: ReactElement;
75
+ control: Control<any>;
76
+ errors: FieldErrors;
77
+ buttonText: string;
78
+ formError?: {
79
+ non_field_errors?: string;
80
+ status?: string;
81
+ [key: string]: any;
82
+ };
83
+ isSubmitting?: boolean;
84
+ }
85
+
86
+ export type CardFormSectionProps = {
87
+ register: any;
48
88
  control: any;
49
89
  errors: any;
50
- buttonText: string;
90
+ months: Array<{ label: string; value: string }>;
91
+ years: Array<{ label: string; value: string }>;
92
+ translations?: IyzicoSavedCardOptionTexts['label'];
93
+ setFormValue?: any;
94
+ };
95
+
96
+ export type IyzicoSavedCardsProps = {
97
+ title?: string;
51
98
  };
@@ -0,0 +1,429 @@
1
+ 'use client';
2
+
3
+ import * as yup from 'yup';
4
+ import { ObjectSchema } from 'yup';
5
+ import { yupResolver } from '@hookform/resolvers/yup';
6
+ import { useForm } from 'react-hook-form';
7
+ import {
8
+ FormHTMLAttributes,
9
+ HTMLAttributes,
10
+ ReactElement,
11
+ useEffect,
12
+ useMemo,
13
+ useState
14
+ } from 'react';
15
+ import { useAppSelector } from '@akinon/next/redux/hooks';
16
+ import {
17
+ useCompleteSavedCardMutation,
18
+ useSetSavedCardMutation
19
+ } from '../redux/api';
20
+
21
+ import {
22
+ AgreementAndSubmitProps,
23
+ CardFormSectionProps,
24
+ ErrorTexts,
25
+ InstallmentSectionProps,
26
+ IyzicoSavedCardOptionTexts,
27
+ IyzicoSavedCardsProps
28
+ } from '../types';
29
+ import { InstallmentSection } from '../components/installment-section';
30
+ import { AgreementAndSubmit } from '../components/agreement-and-submit';
31
+ import { CardFormSection } from '../components/card-form-section';
32
+ import Script from 'next/script';
33
+
34
+ type IyzicoSavedCardOptionProps = {
35
+ texts?: IyzicoSavedCardOptionTexts;
36
+ agreementCheckbox?: ReactElement;
37
+ customFormSchema?: {
38
+ 'new-card'?: ObjectSchema<any>;
39
+ 'existing-card'?: ObjectSchema<any>;
40
+ };
41
+ customRender?: {
42
+ cardFormSection?: (props: CardFormSectionProps) => ReactElement;
43
+ savedCardsSection?: (props: IyzicoSavedCardsProps) => ReactElement;
44
+ installmentSection?: (props: InstallmentSectionProps) => ReactElement;
45
+ agreementAndSubmit?: (props: AgreementAndSubmitProps) => ReactElement;
46
+ };
47
+ formWrapperClassName?: string;
48
+ cardSelectionWrapperClassName?: string;
49
+ installmentWrapperClassName?: string;
50
+ formProps?: FormHTMLAttributes<HTMLFormElement>;
51
+ cardSelectionWrapperProps?: HTMLAttributes<HTMLDivElement>;
52
+ installmentWrapperProps?: HTMLAttributes<HTMLDivElement>;
53
+ };
54
+
55
+ const defaultTranslations: IyzicoSavedCardOptionTexts = {
56
+ title: 'Pay with Iyzico',
57
+ button: 'Complete Payment',
58
+ installment: {
59
+ title: 'Installment Options'
60
+ },
61
+ errors: {
62
+ required: 'This field is required',
63
+ cardNumberLength: 'Card number must be 16 digits long.',
64
+ cvvLength: 'CVV must be 3 digits long.',
65
+ cardHolderMatches: 'Please enter a valid name',
66
+ saveCardRequired: 'Please select the Save My Card option'
67
+ },
68
+ label: {
69
+ cardHolder: 'Cardholder Name',
70
+ cardNumber: 'Card Number',
71
+ expiryDate: 'Expiration Date',
72
+ cvv: 'CVV',
73
+ saveCardForFuture: 'Save this card for future payments'
74
+ },
75
+ placeholder: {
76
+ cardHolder: 'Name on Card',
77
+ cardMonth: 'Month',
78
+ cardYear: 'Year'
79
+ },
80
+ savedCardsTitle: 'Select from your saved cards:',
81
+ paymentErrorMessage: 'An error occurred while processing your payment'
82
+ };
83
+
84
+ const mergeTranslations = (
85
+ customTranslations: IyzicoSavedCardOptionTexts,
86
+ defaultTranslations: IyzicoSavedCardOptionTexts
87
+ ) => {
88
+ return {
89
+ ...defaultTranslations,
90
+ ...customTranslations,
91
+ installment: {
92
+ ...defaultTranslations.installment,
93
+ ...customTranslations.installment
94
+ },
95
+ errors: {
96
+ ...defaultTranslations.errors,
97
+ ...customTranslations.errors
98
+ },
99
+ label: {
100
+ ...defaultTranslations.label,
101
+ ...customTranslations.label
102
+ },
103
+ placeholder: {
104
+ ...defaultTranslations.placeholder,
105
+ ...customTranslations.placeholder
106
+ }
107
+ };
108
+ };
109
+
110
+ const createFormSchema = (
111
+ errors: ErrorTexts,
112
+ type: 'existing-card' | 'new-card'
113
+ ) => {
114
+ const baseSchema = {
115
+ agreement: yup
116
+ .boolean()
117
+ .oneOf([true], errors?.required ?? defaultTranslations.errors.required),
118
+ register_consumer_card: yup.boolean().default(false)
119
+ };
120
+
121
+ if (type === 'new-card') {
122
+ return yup.object().shape({
123
+ ...baseSchema,
124
+ card_holder: yup
125
+ .string()
126
+ .required(errors?.required ?? defaultTranslations.errors.required)
127
+ .matches(
128
+ /^[a-zA-ZğüşöçıİĞÜŞÖÇ\s]+$/,
129
+ errors?.cardHolderMatches ??
130
+ defaultTranslations.errors.cardHolderMatches
131
+ ),
132
+ card_number: yup
133
+ .string()
134
+ .transform((value: string) => value.replace(/_/g, '').replace(/ /g, ''))
135
+ .length(
136
+ 16,
137
+ errors?.cardNumberLength ??
138
+ defaultTranslations.errors.cardNumberLength
139
+ )
140
+ .required(errors?.required ?? defaultTranslations.errors.required),
141
+ card_month: yup
142
+ .string()
143
+ .required(errors?.required ?? defaultTranslations.errors.required),
144
+ card_year: yup
145
+ .string()
146
+ .required(errors?.required ?? defaultTranslations.errors.required),
147
+ card_cvv: yup
148
+ .string()
149
+ .transform((value: string) => value.replace(/_/g, '').replace(/ /g, ''))
150
+ .length(3, errors?.cvvLength ?? defaultTranslations.errors.cvvLength)
151
+ .required(errors?.required ?? defaultTranslations.errors.required)
152
+ });
153
+ }
154
+
155
+ return yup.object().shape(baseSchema);
156
+ };
157
+
158
+ const IyzicoSavedCardOption = ({
159
+ texts = defaultTranslations,
160
+ agreementCheckbox,
161
+ customFormSchema,
162
+ customRender,
163
+ formWrapperClassName = 'flex flex-wrap w-full',
164
+ cardSelectionWrapperClassName = 'w-full flex flex-col xl:w-6/10',
165
+ installmentWrapperClassName = 'w-full xl:w-4/10 xl:border-l xl:border-t-0',
166
+ formProps = {},
167
+ cardSelectionWrapperProps = {},
168
+ installmentWrapperProps = {}
169
+ }: IyzicoSavedCardOptionProps) => {
170
+ const mergedTexts = useMemo(
171
+ () => mergeTranslations(texts, defaultTranslations),
172
+ [texts]
173
+ );
174
+ const [completeSavedCard] = useCompleteSavedCardMutation();
175
+ const [setSavedCard] = useSetSavedCardMutation();
176
+ const installmentOptions = useAppSelector(
177
+ (state) => state.checkout.installmentOptions
178
+ );
179
+ const ucs = useAppSelector((state) => state.savedCard.ucs);
180
+ const [type, setType] = useState<'new-card' | 'existing-card'>('new-card');
181
+ const [months, setMonths] = useState([]);
182
+ const [years, setYears] = useState([]);
183
+ const [formError, setFormError] = useState(null);
184
+
185
+ const getFormSchema = () => {
186
+ if (customFormSchema?.[type]) {
187
+ return customFormSchema[type];
188
+ }
189
+ return createFormSchema(mergedTexts.errors, type);
190
+ };
191
+
192
+ const {
193
+ register,
194
+ handleSubmit,
195
+ control,
196
+ formState: { errors, isSubmitting },
197
+ setValue: setFormValue
198
+ } = useForm({
199
+ resolver: yupResolver(getFormSchema()) as any
200
+ });
201
+
202
+ const onSubmit = async (data) => {
203
+ if (isSubmitting) return;
204
+
205
+ if (!window.iyziUcsInit) {
206
+ return;
207
+ }
208
+
209
+ setFormError(null);
210
+
211
+ try {
212
+ if (type === 'existing-card') {
213
+ const res = await completeSavedCard({
214
+ agreement: data.agreement,
215
+ consumer_token: window.universalCardStorage.consumerToken,
216
+ card_token: window.universalCardStorage.cardToken,
217
+ register_consumer_card: true
218
+ });
219
+
220
+ if ('data' in res && res.data?.errors) {
221
+ setFormError(res.data.errors);
222
+ }
223
+ } else {
224
+ const res = await completeSavedCard({
225
+ agreement: data.agreement,
226
+ card_holder: data.card_holder,
227
+ card_number: data.card_number.replaceAll(' ', ''),
228
+ card_month: data.card_month,
229
+ card_year: data.card_year,
230
+ card_cvv: data.card_cvv,
231
+ register_consumer_card: data.register_consumer_card || false
232
+ });
233
+
234
+ if ('data' in res && res.data?.errors) {
235
+ setFormError(res.data.errors);
236
+ }
237
+ }
238
+ } catch (error) {
239
+ console.error('Error completing saved card:', error);
240
+ setFormError({
241
+ non_field_errors:
242
+ mergedTexts.paymentErrorMessage ??
243
+ defaultTranslations.paymentErrorMessage
244
+ });
245
+ }
246
+ };
247
+
248
+ const ucsScript = ucs?.script && (
249
+ <Script
250
+ id="saved_card"
251
+ strategy="afterInteractive"
252
+ dangerouslySetInnerHTML={{ __html: ucs.script }}
253
+ />
254
+ );
255
+
256
+ useEffect(() => {
257
+ window.iyziUcsInit?.createTag();
258
+
259
+ const monthsList = [
260
+ {
261
+ label:
262
+ mergedTexts.placeholder?.cardMonth ??
263
+ defaultTranslations.placeholder.cardMonth,
264
+ value: ''
265
+ }
266
+ ];
267
+ const yearsList = [
268
+ {
269
+ label:
270
+ mergedTexts.placeholder?.cardYear ??
271
+ defaultTranslations.placeholder.cardYear,
272
+ value: ''
273
+ }
274
+ ];
275
+ const date = new Date();
276
+ const currentYear = date.getFullYear();
277
+
278
+ for (let i = 1; i <= 12; i++) {
279
+ monthsList.push({ label: i.toString(), value: i.toString() });
280
+ }
281
+
282
+ for (let i = currentYear; i < currentYear + 13; i++) {
283
+ yearsList.push({ label: i.toString(), value: i.toString() });
284
+ }
285
+
286
+ setMonths(monthsList);
287
+ setYears(yearsList);
288
+
289
+ if (window.iyziUcsInit?.ucsToken) {
290
+ setSavedCard({
291
+ card: window.iyziUcsInit.ucsToken
292
+ });
293
+ }
294
+ }, []);
295
+
296
+ useEffect(() => {
297
+ if (window.iyziUcsInit?.scriptType === 'CONSUMER_WITH_CARD_EXIST') {
298
+ if (window.universalCardStorage?.cardToken) {
299
+ setType('existing-card');
300
+ } else {
301
+ setType('new-card');
302
+ }
303
+ } else {
304
+ setType('new-card');
305
+ }
306
+
307
+ const checkboxInterval = setInterval(() => {
308
+ if (window.universalCardStorage?.cardToken !== undefined) {
309
+ setType('existing-card');
310
+ } else {
311
+ setType('new-card');
312
+ }
313
+ }, 100);
314
+
315
+ return () => clearInterval(checkboxInterval);
316
+ }, [type]);
317
+
318
+ return (
319
+ <>
320
+ <form
321
+ className={formWrapperClassName}
322
+ onSubmit={handleSubmit(onSubmit)}
323
+ {...formProps}
324
+ >
325
+ <div
326
+ className={cardSelectionWrapperClassName}
327
+ {...cardSelectionWrapperProps}
328
+ >
329
+ {mergedTexts.title && (
330
+ <div className="border-solid border-gray-400 px-4 py-2">
331
+ <span className="text-black-800 text-lg font-medium">
332
+ {mergedTexts.title}
333
+ </span>
334
+ </div>
335
+ )}
336
+
337
+ {window.iyziUcsInit?.scriptType === 'CONSUMER_WITH_CARD_EXIST' && (
338
+ <>
339
+ {customRender?.savedCardsSection ? (
340
+ customRender.savedCardsSection({
341
+ title: mergedTexts.savedCardsTitle
342
+ })
343
+ ) : (
344
+ <div>
345
+ <h3 className="text-sm font-medium text-gray-800 mb-3 px-4 sm:px-6">
346
+ {mergedTexts.savedCardsTitle}
347
+ </h3>
348
+ <div id="ucs-cards" className="mb-3 px-4 sm:px-6"></div>
349
+ </div>
350
+ )}
351
+ </>
352
+ )}
353
+
354
+ {type === 'new-card' && (
355
+ <>
356
+ {customRender?.cardFormSection ? (
357
+ customRender.cardFormSection({
358
+ register,
359
+ control,
360
+ errors,
361
+ months,
362
+ years,
363
+ translations: mergedTexts.label,
364
+ setFormValue
365
+ })
366
+ ) : (
367
+ <CardFormSection
368
+ register={register}
369
+ control={control}
370
+ errors={errors}
371
+ months={months}
372
+ years={years}
373
+ translations={mergedTexts.label}
374
+ />
375
+ )}
376
+ </>
377
+ )}
378
+ </div>
379
+
380
+ <div
381
+ className={installmentWrapperClassName}
382
+ {...installmentWrapperProps}
383
+ >
384
+ {customRender?.installmentSection ? (
385
+ customRender.installmentSection({
386
+ title: mergedTexts.installment?.title,
387
+ installmentOptions,
388
+ translations: mergedTexts.installment,
389
+ errors: errors.installment,
390
+ setFormValue
391
+ })
392
+ ) : (
393
+ <InstallmentSection
394
+ title={mergedTexts.installment?.title}
395
+ installmentOptions={installmentOptions}
396
+ translations={mergedTexts.installment}
397
+ errors={errors.installment}
398
+ />
399
+ )}
400
+
401
+ <div className="flex flex-col text-xs">
402
+ {customRender?.agreementAndSubmit ? (
403
+ customRender.agreementAndSubmit({
404
+ agreementCheckbox,
405
+ control,
406
+ errors,
407
+ buttonText: mergedTexts.button,
408
+ formError,
409
+ isSubmitting
410
+ })
411
+ ) : (
412
+ <AgreementAndSubmit
413
+ agreementCheckbox={agreementCheckbox}
414
+ control={control}
415
+ errors={errors}
416
+ buttonText={mergedTexts.button}
417
+ formError={formError}
418
+ isSubmitting={isSubmitting}
419
+ />
420
+ )}
421
+ </div>
422
+ </div>
423
+ </form>
424
+ {ucsScript}
425
+ </>
426
+ );
427
+ };
428
+
429
+ export default IyzicoSavedCardOption;
@@ -3,8 +3,9 @@
3
3
  import * as yup from 'yup';
4
4
  import { yupResolver } from '@hookform/resolvers/yup';
5
5
  import { useForm } from 'react-hook-form';
6
- import React, { ReactElement, useMemo } from 'react';
6
+ import React, { ReactElement, useMemo, useState } from 'react';
7
7
  import { useAppDispatch, useAppSelector } from '@akinon/next/redux/hooks';
8
+ import { checkPaymentWillRedirect } from '@akinon/next/utils';
8
9
  import {
9
10
  useCompleteSavedCardMutation,
10
11
  useSetSavedCardMutation
@@ -95,7 +96,7 @@ const SavedCardOption = ({
95
96
  customRender,
96
97
  formWrapperClassName = 'flex flex-wrap w-full',
97
98
  cardSelectionWrapperClassName = 'w-full flex flex-col xl:w-6/10',
98
- installmentWrapperClassName = 'w-full xl:w-4/10 xl:border-l xl:border-t-0 xl:border-gray-200',
99
+ installmentWrapperClassName = 'w-full xl:w-4/10 xl:border-l xl:border-t-0',
99
100
  formProps = {},
100
101
  cardSelectionWrapperProps = {},
101
102
  installmentWrapperProps = {}
@@ -105,7 +106,9 @@ const SavedCardOption = ({
105
106
  [texts]
106
107
  );
107
108
  const dispatch = useAppDispatch();
108
- const [completeSavedCard] = useCompleteSavedCardMutation();
109
+ const [isProcessing, setIsProcessing] = useState(false);
110
+ const [completeSavedCard, { isLoading: isCompleting }] =
111
+ useCompleteSavedCardMutation();
109
112
  const [setSavedCard] = useSetSavedCardMutation();
110
113
  const installmentOptions = useAppSelector(
111
114
  (state) => state.checkout.installmentOptions
@@ -116,11 +119,11 @@ const SavedCardOption = ({
116
119
  register,
117
120
  handleSubmit,
118
121
  control,
119
- formState: { errors },
122
+ formState: { errors, isSubmitting },
120
123
  setValue,
121
124
  watch
122
125
  } = useForm({
123
- resolver: yupResolver(createFormSchema(mergedTexts.errors))
126
+ resolver: yupResolver(createFormSchema(mergedTexts.errors)) as any
124
127
  });
125
128
 
126
129
  const selectedCardToken = watch('card');
@@ -129,15 +132,26 @@ const SavedCardOption = ({
129
132
  [cards, selectedCardToken]
130
133
  );
131
134
 
135
+ const isButtonDisabled = isSubmitting || isCompleting || isProcessing;
136
+
132
137
  const handleCardSelection = async (card) => {
133
138
  await setSavedCard({ card }).unwrap();
134
139
  setValue('card', card.token, { shouldValidate: true });
135
140
  };
136
141
 
137
142
  const onSubmit = async () => {
143
+ if (isButtonDisabled) return;
144
+
145
+ setIsProcessing(true);
146
+
138
147
  try {
139
- await completeSavedCard({ agreement: true });
148
+ const response = await completeSavedCard({ agreement: true }).unwrap();
149
+
150
+ if (response?.errors || !checkPaymentWillRedirect(response)) {
151
+ setIsProcessing(false);
152
+ }
140
153
  } catch (error) {
154
+ setIsProcessing(false);
141
155
  console.error('Error completing saved card:', error);
142
156
  }
143
157
  };
@@ -183,7 +197,6 @@ const SavedCardOption = ({
183
197
  {customRender?.installmentSection ? (
184
198
  customRender.installmentSection({
185
199
  title: mergedTexts.installment?.title,
186
- selectedCard,
187
200
  installmentOptions,
188
201
  translations: mergedTexts.installment,
189
202
  errors: errors.installment
@@ -191,7 +204,6 @@ const SavedCardOption = ({
191
204
  ) : (
192
205
  <InstallmentSection
193
206
  title={mergedTexts.installment?.title}
194
- selectedCard={selectedCard}
195
207
  installmentOptions={installmentOptions}
196
208
  translations={mergedTexts.installment}
197
209
  errors={errors.installment}
@@ -203,7 +215,8 @@ const SavedCardOption = ({
203
215
  agreementCheckbox,
204
216
  control,
205
217
  errors,
206
- buttonText: mergedTexts.button
218
+ buttonText: mergedTexts.button,
219
+ isSubmitting: isButtonDisabled
207
220
  })
208
221
  ) : (
209
222
  <AgreementAndSubmit
@@ -211,6 +224,7 @@ const SavedCardOption = ({
211
224
  control={control}
212
225
  errors={errors}
213
226
  buttonText={mergedTexts.button}
227
+ isSubmitting={isButtonDisabled}
214
228
  />
215
229
  )}
216
230
  </div>