├── .gitignore
├── LICENSE
├── README.md
├── config-overrides.js
├── manuals
├── CERTIFICATION.md
└── PAYMENT.md
├── package.json
├── public
├── favicon.ico
├── index.html
└── manifest.json
├── src
├── App.js
├── App.test.js
├── Certification
│ └── index.js
├── CertificationResult
│ └── index.js
├── Home
│ └── index.js
├── Payment
│ ├── constants.js
│ ├── index.js
│ └── utils.js
├── PaymentResult
│ └── index.js
├── index.css
├── index.js
└── serviceWorker.js
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .idea
3 | build
4 | node_modules
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Solee Choi
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | # iamport-react-example
3 | [  ](https://github.com/facebook/react/)
4 | [  ](https://github.com/ant-design/ant-design)
5 | [  ](https://github.com/styled-components/styled-components)
6 |
7 | 리액트 환경에서 아임포트 결제 및 휴대폰 본인인증 연동을 위한 예제입니다.
8 |
9 | ## 다운받기
10 |
11 | ```
12 | $ git clone https://github.com/iamport/iamport-react-example
13 | ```
14 |
15 | ## 실행하기
16 |
17 | ```
18 | $ cd ./iamport-react-example
19 | $ yarn
20 | $ yarn start
21 | ```
22 |
23 | ## 아임포트 라이브러리 추가하기
24 |
25 | 최상단 HTML(public/index.html)에 아래의 `
30 |
31 |
32 | ```
33 |
34 | ## 결제 연동하기
35 |
36 | 결제 연동 방법은 [리액트에서 아임포트 결제 연동하기](manuals/PAYMENT.md) 문서를 참고하세요.
37 |
38 | ## 휴대폰 본인인증 연동하기
39 |
40 | 휴대폰 본인인증 연동 방법은 [리액트에서 아임포트 휴대폰 본인인증 연동하기](manuals/CERTIFICATION.md) 문서를 참고하세요.
41 |
--------------------------------------------------------------------------------
/config-overrides.js:
--------------------------------------------------------------------------------
1 | const { override, fixBabelImports, addLessLoader } = require('customize-cra');
2 |
3 | module.exports = override(
4 | fixBabelImports('import', {
5 | libraryName: 'antd',
6 | libraryDirectory: 'es',
7 | style: true,
8 | }),
9 | addLessLoader({
10 | javascriptEnabled: true,
11 | modifyVars: { '@primary-color': '#344e81' },
12 | }),
13 | );
--------------------------------------------------------------------------------
/manuals/CERTIFICATION.md:
--------------------------------------------------------------------------------
1 |
2 | # 리액트에서 아임포트 휴대폰 본인인증 연동하기
3 |
4 | 리액트 환경에서 아임포트 휴대폰 본인인증 연동을 위한 안내입니다.
5 |
6 | ## 1. 가맹점 식별하기
7 |
8 | `IMP` 객체의 `init` 함수 첫번째 인자에 `가맹점 식별코드`를 추가합니다.
9 |
10 | ```javascript
11 | const { IMP } = window;
12 | IMP.init('imp00000000'); // 'imp00000000' 대신 발급받은 가맹점 식별코드를 사용합니다.
13 | ```
14 |
15 | 가맹점 식별코드는 아임포트 관리자 페이지 로그인 후, 시스템 설정 > 내정보에서 확인하실 수 있습니다.
16 |
17 | ## 2. 본인인증 데이터 정의하기
18 |
19 | 본인인증에 필요한 데이터를 아래와 같이 정의합니다. 이때 정의한 데이터는 `IMP.certification` 함수 호출시, 첫번째 인자로 전달됩니다. 본인인증 데이터에 대한 보다 자세한 내용은 아임포트 공식 문서를 참고하세요.
20 |
21 | ```javascript
22 | const data = {
23 | merchant_uid: `mid_${new Date().getTime()}` // 주문번호
24 | company: '아임포트', // 회사명 또는 URL
25 | carrier: 'SKT', // 통신사
26 | name: '홍길동', // 이름
27 | phone: '01012341234', // 전화번호
28 | ...
29 | };
30 | ```
31 |
32 | ## 3. 콜백 함수 정의하기
33 |
34 | 본인인증 후 실행될 로직을 콜백 함수로 정의합니다. 이때 정의한 함수는 `IMP.certification` 함수 호출시, 두번째 인자로 전달됩니다. 콜백 함수의 첫번째 인자로 본인인증 결과가 객체의 형태로 전달됩니다.
35 |
36 | ```javascript
37 | function callback(response) {
38 | const {
39 | success,
40 | merchant_uid,
41 | error_msg,
42 | ...
43 | } = response;
44 |
45 | if (success) {
46 | alert('본인인증 성공');
47 | } else {
48 | alert(`본인인증 실패: ${error_msg}`);
49 | }
50 | }
51 | ```
52 |
53 | ## 4. 본인인증 창 호출하기
54 |
55 | 본인인증 하기 버튼을 눌렀을때 `IMP` 객체의 `certification` 함수를 호출해 본인인증 창을 호출합니다. `certification` 함수의 첫번째 인자로는 2에서 정의한 `본인인증 데이터`를, 두번째 인자로는 3에서 정의한 `콜백 함수`를 전달합니다.
56 |
57 | ```javascript
58 | import React from 'react';
59 |
60 | function Certification() {
61 | function onClickCertification() {
62 | /* 1. 가맹점 식별하기 */
63 | const { IMP } = window;
64 | IMP.init('imp00000000');
65 |
66 | /* 2. 본인인증 데이터 정의하기 */
67 | const data = {
68 | merchant_uid: `mid_${new Date().getTime()}` // 주문번호
69 | company: '아임포트', // 회사명 또는 URL
70 | carrier: 'SKT', // 통신사
71 | name: '홍길동', // 이름
72 | phone: '01012341234', // 전화번호
73 | ...
74 | };
75 |
76 | /* 4. 본인인증 창 호출하기 */
77 | IMP.certification(data, callback);
78 | }
79 |
80 | /* 3. 콜백 함수 정의하기 */
81 | function callback(response) {
82 | const {
83 | success,
84 | merchant_uid,
85 | error_msg,
86 | ...
87 | } = response;
88 |
89 | if (success) {
90 | alert('본인인증 성공');
91 | } else {
92 | alert(`본인인증 실패: ${error_msg}`);
93 | }
94 | }
95 |
96 | return (
97 | ...
98 |
99 | ...
100 | );
101 | }
102 | ```
103 |
104 | ## 5. 리액트 네이티브 환경에 대응하기
105 |
106 | 리액트 네이티브에서 해당 본인인증 화면을 웹뷰로 띄워 재사용하는 경우가 있습니다. 이 경우 본인인증 하기 버튼을 눌렀을때 본인인증 환경이 리액트 네이티브인지 판단해, `IMP.certification` 함수 호출이 아닌, **리액트 네이티브로 post message를 보내야** 합니다. 리액트 네이티브에 아임포트 리액트 네이티브 모듈을 설치한 후, 리액트로부터 post message를 받으면 해당 본인인증 화면을 렌더링 하는 로직을 추가해야 합니다.
107 |
108 | ### 5-1. 리액트 네이티브로 post message 보내기
109 |
110 | 본인인증 하기 버튼을 눌렀을 때 본인인증 환경을 판단하는 로직을 추가합니다. 본인인증 환경이 리액트 네이티브인 경우, **리액트 네이티브로 `가맹점 식별코드`, `본인인증 데이터` 그리고 `액션 유형`을 post message로 보냅니다.**
111 |
112 | ```javascript
113 | import React from 'react';
114 |
115 | function Certification() {
116 | function onClickCertification() {
117 | const userCode = 'imp00000000';
118 |
119 | /* 2. 본인인증 데이터 정의하기 */
120 | const data = {
121 | merchant_uid: `mid_${new Date().getTime()}` // 주문번호
122 | company: '아임포트', // 회사명 또는 URL
123 | carrier: 'SKT', // 통신사
124 | name: '홍길동', // 이름
125 | phone: '01012341234', // 전화번호
126 | ...
127 | };
128 |
129 | if (isReactNative()) {
130 | /* 5. 리액트 네이티브 환경에 대응하기 */
131 | const params = {
132 | userCode, // 가맹점 식별코드
133 | data, // 본인인증 데이터
134 | type: 'certification', // 결제와 본인인증 구분을 위한 필드
135 | };
136 | const paramsToString = JSON.stringify(params);
137 | window.ReactNativeWebView.postMessage(paramsToString);
138 | } else {
139 | /* 1. 가맹점 식별하기 */
140 | const { IMP } = window;
141 | IMP.init(userCode);
142 | /* 4. 본인인증 창 호출하기 */
143 | IMP.certification(data, callback);
144 | }
145 | }
146 |
147 | /* 3. 콜백 함수 정의하기 */
148 | function callback(response) {
149 | const {
150 | success,
151 | merchant_uid,
152 | error_msg,
153 | ...
154 | } = response;
155 |
156 | if (success) {
157 | alert('본인인증 성공');
158 | } else {
159 | alert(`본인인증 실패: ${error_msg}`);
160 | }
161 | }
162 |
163 | function isReactNative() {
164 | /*
165 | 리액트 네이티브 환경인지 여부를 판단해
166 | 리액트 네이티브의 경우 IMP.certification()를 호출하는 대신
167 | iamport-react-native 모듈로 post message를 보낸다
168 |
169 | 아래 예시는 모든 모바일 환경을 리액트 네이티브로 인식한 것으로
170 | 실제로는 user agent에 값을 추가해 정확히 판단해야 한다
171 | */
172 | if (ua.mobile) return true;
173 | return false;
174 | }
175 |
176 | return (
177 | ...
178 |
179 | ...
180 | );
181 | }
182 | ```
183 |
184 | ### 5-2. 리액트 네이티브에 아임포트 모듈 설치 및 설정하기
185 |
186 | - 아임포트 리액트 네이티브 모듈 설치하기
187 | - 아임포트 리액트 네이티브 모듈 설정하기
188 |
189 | ### 5-3. 리액트 네이티브에서 post message를 받았을때 본인인증 화면 렌더링하기
190 |
191 | 리액트에서 post message를 보내면, WebView의 `onMessage` 함수가 이를 트리거합니다. 메시지 내용 중 액션 유형(`type`)이 `payment`면 본인인증 화면을, `certification`이면 본인인증 화면을 렌더링 하기 위해 해당 라우트로 이동합니다. 이때 **post message로 전달 받은 `가맹점 식별코드`와 `본인인증 데이터`를 `query`로 전달**합니다.
192 |
193 | ```javascript
194 | import React, { useState, useEffect } from 'react';
195 | import WebView from 'react-native-webview';
196 | import queryString from 'query-string';
197 |
198 | function Home({ navigation }) {
199 | function onMessage(e) {
200 | /* 리액트로부터 post message를 받았을때 트리거 된다 */
201 | try {
202 | /* post message에서 가맹점 식별코드, 본인인증 데이터 그리고 액션 유형을 추출한다 */
203 | const { userCode, data, type } = JSON.parse(e.nativeEvent.data);
204 | const params = { userCode, data };
205 | /* 본인인증 화면으로 이동한다 */
206 | navigation.push(type === 'payment' ? 'Payment' : 'Certification', params);
207 | } catch (e) {}
208 | }
209 |
210 | return (
211 |
221 | );
222 | }
223 |
224 | export default Home;
225 | ```
226 |
227 | ### 5-4. 리액트 네이티브에 본인인증 화면 추가하기
228 |
229 | `가맹점 식별코드`와 `본인인증 데이터`를 쿼리에서 추출해 `IMP.Payment`에 prop 형태로 전달합니다. 이때 본인인증 후 실행될 로직을 작성한 콜백 함수도 함께 전달합니다. 콜백함수에서 본인인증 결과에 따라 로직을 다르게 작성할 수 있습니다. 아래는 본인인증 성공시 웹뷰를 띄운 Home으로 돌아가고, 본인인증 실패시 바로 이전 화면으로 돌아가는 예시입니다.
230 |
231 | ```javascript
232 | import React from 'react';
233 | import IMP from 'iamport-react-native';
234 |
235 | import Loading from './Loading';
236 |
237 | function Certification({ navigation }) {
238 | /* 가맹점 식별코드, 본인인증 데이터 추출 */
239 | const userCode = navigation.getParam('userCode');
240 | const data = navigation.getParam('data');
241 |
242 | /* 본인인증 후 실행될 콜백 함수 입력 */
243 | function callback(response) {
244 | const isSuccessed = getIsSuccessed(response);
245 | if (isSuccessed) {
246 | /* 본인인증 성공한 경우, 리디렉션 위해 홈으로 이동한다 */
247 | const params = {
248 | ...response,
249 | type: 'certification', // 결제와 본인인증 구분을 위한 필드
250 | };
251 | navigation.replace('Home', params);
252 | } else {
253 | /* 본인인증 실패한 경우, 이전 화면으로 돌아간다 */
254 | navigation.goBack();
255 | }
256 | }
257 |
258 | function getIsSuccessed(response) {
259 | const { success } = response;
260 |
261 | if (typeof success === 'string') return success === 'true';
262 | if (typeof success === 'boolean') return success === true;
263 | }
264 |
265 | return (
266 | }
269 | data={{
270 | ...data,
271 | app_scheme: 'test://',
272 | }}
273 | callback={callback}
274 | />
275 | );
276 | }
277 |
278 | export default Certification;
279 | ```
280 |
281 | ### 5-5. 본인인증 후 리디렉션 설정하기
282 |
283 | 위의 예시에 따라 본인인증 후, 웹뷰를 띄운 Home으로 돌아갔을때 리디렉션을 위한 추가 로직을 작성해야 합니다. 아래와 같은 경우를 가정합니다.
284 |
285 | | 유형 | 도메인 |
286 | | ---------- | ------------------------------------------ |
287 | | 홈 | https://example.com |
288 | | 본인인증 | https://example.com/certification |
289 | | 본인인증 완료 | https://example.com/certification/result |
290 |
291 | 위와 같은 경우, 본인인증 후 홈으로 렌더링 시 웹뷰의 도메인은 다시 `https://example.com`이 됩니다. 이를 `https://example.com/certification/result`로 리디렉션 하기 위해 홈 컴포넌트에 아래와 같은 로직을 작성합니다.
292 |
293 | ```javascript
294 | import React, { useState, useEffect } from 'react';
295 | import WebView from 'react-native-webview';
296 | import queryString from 'query-string';
297 |
298 | const domain = 'https://example.com'; // 가맹점 도메인
299 | function Home({ navigation }) {
300 | const [uri, setUri] = useState(domain);
301 |
302 | useEffect(() => {
303 | /* navigation이 바뀌었을때를 트리거 */
304 | const response = navigation.getParam('response');
305 | if (response) {
306 | const query = queryString.stringify(response);
307 | const { type } = query;
308 | if (type === 'certification') {
309 | /* 본인인증 후 렌더링 되었을 경우, https://example.com/certification/result로 리디렉션 시킨다 */
310 | setUri(`${domain}/certification/result?${query}`);
311 | }
312 | ...
313 | }
314 | }, [navigation]);
315 |
316 | function onMessage(e) {
317 | try {
318 | const { userCode, data, type } = JSON.parse(e.nativeEvent.data);
319 | const params = { userCode, data };
320 | navigation.push(type === 'payment' ? 'Payment' : 'Certification', params);
321 | } catch (e) {}
322 | }
323 |
324 | return (
325 |
335 | );
336 | }
337 |
338 | export default Home;
339 | ```
340 |
--------------------------------------------------------------------------------
/manuals/PAYMENT.md:
--------------------------------------------------------------------------------
1 |
2 | # 리액트에서 아임포트 결제 연동하기
3 |
4 | 리액트 환경에서 아임포트 결제 연동을 위한 안내입니다.
5 |
6 | ## 1. 가맹점 식별하기
7 |
8 | `IMP` 객체의 `init` 함수 첫번째 인자에 `가맹점 식별코드`를 추가합니다.
9 |
10 | ```javascript
11 | const { IMP } = window;
12 | IMP.init('imp00000000'); // 'imp00000000' 대신 발급받은 가맹점 식별코드를 사용합니다.
13 | ```
14 |
15 | 가맹점 식별코드는 아임포트 관리자 페이지 로그인 후, 시스템 설정 > 내정보에서 확인하실 수 있습니다.
16 |
17 | ## 2. 결제 데이터 정의하기
18 |
19 | 결제에 필요한 데이터를 아래와 같이 정의합니다. 이때 정의한 데이터는 `IMP.request_pay` 함수 호출시, 첫번째 인자로 전달됩니다. 결제 데이터에 대한 보다 자세한 내용은 아임포트 공식 문서를 참고하세요.
20 |
21 | ```javascript
22 | const data = {
23 | pg: 'html5_inicis', // PG사
24 | pay_method: 'card', // 결제수단
25 | merchant_uid: `mid_${new Date().getTime()}` // 주문번호
26 | amount: 1000, // 결제금액
27 | name: '아임포트 결제 데이터 분석', // 주문명
28 | buyer_name: '홍길동', // 구매자 이름
29 | buyer_tel: '01012341234', // 구매자 전화번호
30 | buyer_email: 'example@example', // 구매자 이메일
31 | buyer_addr: '신사동 661-16', // 구매자 주소
32 | buyer_postcode: '06018', // 구매자 우편번호
33 | ...
34 | };
35 | ```
36 |
37 | ## 3. 콜백 함수 정의하기
38 |
39 | 결제 후 실행될 로직을 콜백 함수로 정의합니다. 이때 정의한 함수는 `IMP.request_pay` 함수 호출시, 두번째 인자로 전달됩니다. 콜백 함수의 첫번째 인자로 결제 결과가 객체의 형태로 전달됩니다. 결제 결과에 대한 보다 자세한 내용은 아임포트 공식 문서를 참고하세요.
40 |
41 | ```javascript
42 | function callback(response) {
43 | const {
44 | success,
45 | merchant_uid,
46 | error_msg,
47 | ...
48 | } = response;
49 |
50 | if (success) {
51 | alert('결제 성공');
52 | } else {
53 | alert(`결제 실패: ${error_msg}`);
54 | }
55 | }
56 | ```
57 |
58 | ## 4. 결제 창 호출하기
59 |
60 | 결제하기 버튼을 눌렀을때 `IMP` 객체의 `request_pay` 함수를 호출해 결제 창을 호출합니다. `request_pay` 함수의 첫번째 인자로는 2에서 정의한 `결제 데이터`를, 두번째 인자로는 3에서 정의한 `콜백 함수`를 전달합니다.
61 |
62 | ```javascript
63 | import React from 'react';
64 |
65 | function Payment() {
66 | function onClickPayment() {
67 | /* 1. 가맹점 식별하기 */
68 | const { IMP } = window;
69 | IMP.init('imp00000000');
70 |
71 | /* 2. 결제 데이터 정의하기 */
72 | const data = {
73 | pg: 'html5_inicis', // PG사
74 | pay_method: 'card', // 결제수단
75 | merchant_uid: `mid_${new Date().getTime()}` // 주문번호
76 | amount: 1000, // 결제금액
77 | name: '아임포트 결제 데이터 분석', // 주문명
78 | buyer_name: '홍길동', // 구매자 이름
79 | buyer_tel: '01012341234', // 구매자 전화번호
80 | buyer_email: 'example@example', // 구매자 이메일
81 | buyer_addr: '신사동 661-16', // 구매자 주소
82 | buyer_postcode: '06018', // 구매자 우편번호
83 | ...
84 | };
85 |
86 | /* 4. 결제 창 호출하기 */
87 | IMP.request_pay(data, callback);
88 | }
89 |
90 | /* 3. 콜백 함수 정의하기 */
91 | function callback(response) {
92 | const {
93 | success,
94 | merchant_uid,
95 | error_msg,
96 | ...
97 | } = response;
98 |
99 | if (success) {
100 | alert('결제 성공');
101 | } else {
102 | alert(`결제 실패: ${error_msg}`);
103 | }
104 | }
105 |
106 | return (
107 | ...
108 |
109 | ...
110 | );
111 | }
112 | ```
113 |
114 | ## 5. 리액트 네이티브 환경에 대응하기
115 |
116 | 리액트 네이티브에서 해당 결제 화면을 웹뷰로 띄워 재사용하는 경우가 있습니다. 이 경우 결제하기 버튼을 눌렀을때 결제 환경이 리액트 네이티브인지 판단해, `IMP.request_pay` 함수 호출이 아닌, **리액트 네이티브로 post message를 보내야** 합니다. 리액트 네이티브에 아임포트 리액트 네이티브 모듈을 설치한 후, 리액트로부터 post message를 받으면 해당 결제 화면을 렌더링 하는 로직을 추가해야 합니다.
117 |
118 | ### 5-1. 리액트 네이티브로 post message 보내기
119 |
120 | 결제 하기 버튼을 눌렀을 때 결제 환경을 판단하는 로직을 추가합니다. 결제 환경이 리액트 네이티브인 경우, **리액트 네이티브로 `가맹점 식별코드`, `결제 데이터` 그리고 `액션 유형`을 post message로 보냅니다.**
121 |
122 | ```javascript
123 | import React from 'react';
124 |
125 | function Payment() {
126 | function onClickPayment() {
127 | const userCode = 'imp00000000';
128 |
129 | /* 2. 결제 데이터 정의하기 */
130 | const data = {
131 | pg: 'html5_inicis', // PG사
132 | pay_method: 'card', // 결제수단
133 | merchant_uid: `mid_${new Date().getTime()}` // 주문번호
134 | amount: 1000, // 결제금액
135 | name: '아임포트 결제 데이터 분석', // 주문명
136 | buyer_name: '홍길동', // 구매자 이름
137 | buyer_tel: '01012341234', // 구매자 전화번호
138 | buyer_email: 'example@example', // 구매자 이메일
139 | buyer_addr: '신사동 661-16', // 구매자 주소
140 | buyer_postcode: '06018', // 구매자 우편번호
141 | ...
142 | };
143 |
144 | if (isReactNative()) {
145 | /* 5. 리액트 네이티브 환경에 대응하기 */
146 | const params = {
147 | userCode, // 가맹점 식별코드
148 | data, // 결제 데이터
149 | type: 'payment', // 결제와 본인인증 구분을 위한 필드
150 | };
151 | const paramsToString = JSON.stringify(params);
152 | window.ReactNativeWebView.postMessage(paramsToString);
153 | } else {
154 | /* 1. 가맹점 식별하기 */
155 | const { IMP } = window;
156 | IMP.init(userCode);
157 | /* 4. 결제 창 호출하기 */
158 | IMP.request_pay(data, callback);
159 | }
160 | }
161 |
162 | /* 3. 콜백 함수 정의하기 */
163 | function callback(response) {
164 | const {
165 | success,
166 | merchant_uid,
167 | error_msg,
168 | ...
169 | } = response;
170 |
171 | if (success) {
172 | alert('결제 성공');
173 | } else {
174 | alert(`결제 실패: ${error_msg}`);
175 | }
176 | }
177 |
178 | function isReactNative() {
179 | /*
180 | 리액트 네이티브 환경인지 여부를 판단해
181 | 리액트 네이티브의 경우 IMP.payment()를 호출하는 대신
182 | iamport-react-native 모듈로 post message를 보낸다
183 |
184 | 아래 예시는 모든 모바일 환경을 리액트 네이티브로 인식한 것으로
185 | 실제로는 user agent에 값을 추가해 정확히 판단해야 한다
186 | */
187 | if (ua.mobile) return true;
188 | return false;
189 | }
190 |
191 | return (
192 | ...
193 |
194 | ...
195 | );
196 | }
197 | ```
198 |
199 | ### 5-2. 리액트 네이티브에 아임포트 모듈 설치하기
200 |
201 | - 아임포트 리액트 네이티브 모듈 설치하기
202 | - 아임포트 리액트 네이티브 모듈 설정하기
203 |
204 | ### 5-3. 리액트 네이티브에서 post message를 받았을때 결제 화면 렌더링하기
205 |
206 | 리액트에서 post message를 보내면, WebView의 `onMessage` 함수가 이를 트리거합니다. 메시지 내용 중 액션 유형(`type`)이 `payment`면 결제 화면을, `certification`이면 본인인증 화면을 렌더링 하기 위해 해당 라우트로 이동합니다. 이때 **post message로 전달 받은 `가맹점 식별코드`와 `결제 데이터`를 `query`로 전달**합니다.
207 |
208 | ```javascript
209 | import React, { useState, useEffect } from 'react';
210 | import WebView from 'react-native-webview';
211 | import queryString from 'query-string';
212 |
213 | function Home({ navigation }) {
214 | function onMessage(e) {
215 | /* 리액트로부터 post message를 받았을때 트리거 된다 */
216 | try {
217 | /* post message에서 가맹점 식별코드, 결제 데이터 그리고 액션 유형을 추출한다 */
218 | const { userCode, data, type } = JSON.parse(e.nativeEvent.data);
219 | const params = { userCode, data };
220 | /* 결제 화면으로 이동한다 */
221 | navigation.push(type === 'payment' ? 'Payment' : 'Certification', params);
222 | } catch (e) {}
223 | }
224 |
225 | return (
226 |
236 | );
237 | }
238 |
239 | export default Home;
240 | ```
241 |
242 | ### 5-4. 리액트 네이티브에 결제 화면 추가하기
243 |
244 | `가맹점 식별코드`와 `결제 데이터`를 쿼리에서 추출해 `IMP.Payment`에 prop 형태로 전달합니다. 이때 결제 후 실행될 로직을 작성한 콜백 함수도 함께 전달합니다. 콜백함수에서 결제 결과에 따라 로직을 다르게 작성할 수 있습니다. 아래는 결제 성공시 웹뷰를 띄운 Home으로 돌아가고, 결제 실패시 바로 이전 화면으로 돌아가는 예시입니다.
245 |
246 | ```javascript
247 | import React from 'react';
248 | import IMP from 'iamport-react-native';
249 |
250 | import Loading from './Loading';
251 |
252 | function Payment({ navigation }) {
253 | /* 가맹점 식별코드, 결제 데이터 추출 */
254 | const userCode = navigation.getParam('userCode');
255 | const data = navigation.getParam('data');
256 |
257 | /* 결제 후 실행될 콜백 함수 입력 */
258 | function callback(response) {
259 | const isSuccessed = getIsSuccessed(response);
260 | if (isSuccessed) {
261 | /* 결제 성공한 경우, 리디렉션 위해 홈으로 이동한다 */
262 | const params = {
263 | ...response,
264 | type: 'payment', // 결제와 본인인증 구분을 위한 필드
265 | };
266 | navigation.replace('Home', params);
267 | } else {
268 | /* 결제 실패한 경우, 이전 화면으로 돌아간다 */
269 | navigation.goBack();
270 | }
271 | }
272 |
273 | function getIsSuccessed(response) {
274 | const { imp_success, success } = response;
275 |
276 | if (typeof imp_success === 'string') return imp_success === 'true';
277 | if (typeof imp_success === 'boolean') return imp_success === true;
278 | if (typeof success === 'string') return success === 'true';
279 | if (typeof success === 'boolean') return success === true;
280 | }
281 |
282 | return (
283 | }
286 | data={{
287 | ...data,
288 | app_scheme: 'test://',
289 | }}
290 | callback={callback}
291 | />
292 | );
293 | }
294 |
295 | export default Payment;
296 | ```
297 |
298 | ### 5-5. 결제 후 리디렉션 설정하기
299 |
300 | 위의 예시에 따라 결제 후, 웹뷰를 띄운 Home으로 돌아갔을때 리디렉션을 위한 추가 로직을 작성해야 합니다. 아래와 같은 경우를 가정합니다.
301 |
302 | | 유형 | 도메인 |
303 | | ------ | ----------------------------------- |
304 | | 홈 | https://example.com |
305 | | 결제 | https://example.com/payment |
306 | | 결제완료 | https://example.com/payment/result |
307 |
308 | 위와 같은 경우, 결제 후 홈으로 렌더링 시 웹뷰의 도메인은 다시 `https://example.com`이 됩니다. 이를 `https://example.com/payment/result`로 리디렉션 하기 위해 홈 컴포넌트에 아래와 같은 로직을 작성합니다.
309 |
310 | ```javascript
311 | import React, { useState, useEffect } from 'react';
312 | import WebView from 'react-native-webview';
313 | import queryString from 'query-string';
314 |
315 | const domain = 'https://example.com'; // 가맹점 도메인
316 | function Home({ navigation }) {
317 | const [uri, setUri] = useState(domain);
318 |
319 | useEffect(() => {
320 | /* navigation이 바뀌었을때를 트리거 */
321 | const response = navigation.getParam('response');
322 | if (response) {
323 | const query = queryString.stringify(response);
324 | const { type } = query;
325 | if (type === 'payment') {
326 | /* 결제 후 렌더링 되었을 경우, https://example.com/payment/result로 리디렉션 시킨다 */
327 | setUri(`${domain}/payment/result?${query}`);
328 | }
329 | ...
330 | }
331 | }, [navigation]);
332 |
333 | function onMessage(e) {
334 | try {
335 | const { userCode, data, type } = JSON.parse(e.nativeEvent.data);
336 | const params = { userCode, data };
337 | navigation.push(type === 'payment' ? 'Payment' : 'Certification', params);
338 | } catch (e) {}
339 | }
340 |
341 | return (
342 |
352 | );
353 | }
354 |
355 | export default Home;
356 | ```
357 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "iamport-react-example",
3 | "version": "0.1.3",
4 | "description": "아임포트 리액트 결제 및 휴대폰 본인인증 연동 예제",
5 | "author": "Solee Deedee Choi",
6 | "keywords": ["iamport", "react", "payment", "certification", "webview", "react-native"],
7 | "dependencies": {
8 | "antd": "^3.20.5",
9 | "customize-cra": "^0.4.1",
10 | "less": "^3.9.0",
11 | "less-loader": "^5.0.0",
12 | "query-string": "^6.8.1",
13 | "react": "^16.8.6",
14 | "react-app-rewired": "^2.1.3",
15 | "react-dom": "^16.8.6",
16 | "react-router-dom": "^5.0.1",
17 | "react-scripts": "3.0.1",
18 | "react-useragent": "^1.1.2",
19 | "styled-components": "^4.3.2"
20 | },
21 | "repository": {
22 | "type": "git",
23 | "url": "https://github.com/SoleeChoi/iamport-react-example"
24 | },
25 | "scripts": {
26 | "start": "react-app-rewired start",
27 | "build": "react-app-rewired build",
28 | "test": "react-app-rewired test",
29 | "eject": "react-app-rewired eject"
30 | },
31 | "eslintConfig": {
32 | "extends": "react-app"
33 | },
34 | "browserslist": {
35 | "production": [
36 | ">0.2%",
37 | "not dead",
38 | "not op_mini all"
39 | ],
40 | "development": [
41 | "last 1 chrome version",
42 | "last 1 firefox version",
43 | "last 1 safari version"
44 | ]
45 | },
46 | "devDependencies": {
47 | "babel-plugin-import": "^1.12.0"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iamport/iamport-react-example/8fc6d3dc7a32ba69b9fbaf90bd789a0bc858b1cf/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
22 | React App
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { BrowserRouter, Route } from 'react-router-dom';
3 |
4 | import Home from './Home';
5 | import Payment from './Payment';
6 | import PaymentResult from './PaymentResult';
7 | import Certification from './Certification';
8 | import CertificationResult from './CertificationResult';
9 |
10 | function App() {
11 | return (
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | }
21 |
22 | export default App;
23 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div');
7 | ReactDOM.render(, div);
8 | ReactDOM.unmountComponentAtNode(div);
9 | });
10 |
--------------------------------------------------------------------------------
/src/Certification/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { Form, Input, Button } from 'antd';
4 | import { withRouter } from 'react-router-dom';
5 | import { withUserAgent } from 'react-useragent';
6 | import queryString from 'query-string';
7 |
8 | const { Item } = Form;
9 |
10 | function Certification({ history, form, ua }) {
11 | const { getFieldDecorator, validateFieldsAndScroll } = form;
12 |
13 | function handleSubmit(e) {
14 | e.preventDefault();
15 |
16 | validateFieldsAndScroll((error, values) => {
17 | if (!error) {
18 | /* 가맹점 식별코드 */
19 | const userCode = 'imp10391932';
20 | /* 결제 데이터 */
21 | const {
22 | merchant_uid,
23 | name,
24 | phone,
25 | min_age,
26 | } = values;
27 |
28 | const data = {
29 | merchant_uid,
30 | };
31 |
32 | if (name) {
33 | data.name = name;
34 | }
35 | if (phone) {
36 | data.phone = phone;
37 | }
38 | if (min_age) {
39 | data.min_age = min_age;
40 | }
41 |
42 | if (isReactNative()) {
43 | /* 리액트 네이티브 환경일때 */
44 | const params = {
45 | userCode,
46 | data,
47 | type: 'certification', // 결제와 본인인증을 구분하기 위한 필드
48 | };
49 | const paramsToString = JSON.stringify(params);
50 | window.ReactNativeWebView.postMessage(paramsToString);
51 | } else {
52 | /* 웹 환경일때 */
53 | const { IMP } = window;
54 | IMP.init(userCode);
55 | IMP.certification(data, callback);
56 | }
57 | }
58 | });
59 | }
60 |
61 | /* 본인인증 후 콜백함수 */
62 | function callback(response) {
63 | const query = queryString.stringify(response);
64 | history.push(`/certification/result?${query}`);
65 | }
66 |
67 | function isReactNative() {
68 | /*
69 | 리액트 네이티브 환경인지 여부를 판단해
70 | 리액트 네이티브의 경우 IMP.certification()을 호출하는 대신
71 | iamport-react-native 모듈로 post message를 보낸다
72 |
73 | 아래 예시는 모든 모바일 환경을 리액트 네이티브로 인식한 것으로
74 | 실제로는 user agent에 값을 추가해 정확히 판단해야 한다
75 | */
76 | if (ua.mobile) return true;
77 | return false;
78 | }
79 |
80 | return (
81 |
82 |
83 |
84 | -
85 | {getFieldDecorator('merchant_uid', {
86 | initialValue: `min_${new Date().getTime()}`,
87 | rules: [{ required: true, message: '주문번호는 필수입력입니다' }],
88 | })(
89 | ,
90 | )}
91 |
92 | -
93 | {getFieldDecorator('name')(
94 | ,
95 | )}
96 |
97 | -
98 | {getFieldDecorator('phone')(
99 | ,
100 | )}
101 |
102 | -
103 | {getFieldDecorator('min_age')(
104 | ,
110 | )}
111 |
112 |
115 |
116 |
117 | );
118 | }
119 |
120 | const Wrapper = styled.div`
121 | padding: 7rem 0;
122 | display: flex;
123 | align-items: center;
124 | justify-content: center;
125 | flex-direction: column;
126 | `;
127 |
128 | const Header = styled.div`
129 | font-weight: bold;
130 | text-align: center;
131 | padding: 2rem;
132 | padding-top: 0;
133 | font-size: 3rem;
134 | `;
135 |
136 | const FormContainer = styled(Form)`
137 | width: 350px;
138 | border-radius: 3px;
139 |
140 | .ant-row {
141 | margin-bottom: 1rem;
142 | }
143 | .ant-form-item {
144 | display: flex;
145 | align-items: center;
146 | }
147 | .ant-col.ant-form-item-label > label::after {
148 | display: none;
149 | }
150 |
151 | .ant-form-explain {
152 | margin-top: 0.5rem;
153 | margin-left: 9rem;
154 | }
155 |
156 | .ant-input-group-addon:first-child {
157 | width: 9rem;
158 | text-align: left;
159 | color: #888;
160 | font-size: 1.2rem;
161 | border: none;
162 | background-color: inherit;
163 | }
164 | .ant-input-group > .ant-input:last-child {
165 | border-radius: 4px;
166 | }
167 |
168 | .ant-col {
169 | width: 100%;
170 | }
171 |
172 | button[type='submit'] {
173 | width: 100%;
174 | height: 5rem;
175 | font-size: 1.6rem;
176 | margin-top: 2rem;
177 | }
178 | `;
179 |
180 | const CertificationForm = Form.create({ name: 'certification' })(Certification);
181 |
182 | export default withUserAgent(withRouter(CertificationForm));
183 |
--------------------------------------------------------------------------------
/src/CertificationResult/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { Icon, Button } from 'antd';
4 | import { withRouter } from 'react-router-dom';
5 | import queryString from 'query-string';
6 |
7 | function CertificationResult({ history }) {
8 | const { location } = history;
9 | const { search } = location;
10 | const query = queryString.parse(search);
11 |
12 | const { merchant_uid, error_msg, imp_uid } = query;
13 | const isSuccessed = getIsSuccessed();
14 | function getIsSuccessed() {
15 | const { success, } = query;
16 | if (typeof success === 'string') return success === 'true';
17 | if (typeof success === 'boolean') return success === true;
18 | }
19 |
20 | const iconType = isSuccessed ? 'check-circle' : 'exclamation-circle';
21 | const resultType = isSuccessed ? '성공' : '실패';
22 | const colorType = isSuccessed ? '#52c41a' : '#f5222d';
23 | return (
24 |
25 |
26 |
27 | {`본인인증에 ${resultType}하였습니다`}
28 |
29 | -
30 | 주문번호
31 | {merchant_uid}
32 |
33 | {isSuccessed ? (
34 | -
35 | 아임포트 번호
36 | {imp_uid}
37 |
38 | ) : (
39 | -
40 | 에러 메시지
41 | {error_msg}
42 |
43 | )}
44 |
45 |
49 |
50 |
51 | );
52 | }
53 |
54 | const Wrapper = styled.div`
55 | position: absolute;
56 | top: 0;
57 | left: 0;
58 | right: 0;
59 | bottom: 0;
60 | `;
61 |
62 | const Container = styled.div`
63 | display: flex;
64 | align-items: center;
65 | justify-content: center;
66 | flex-direction: column;
67 | background-color: #fff;
68 | border-radius: 4px;
69 | position: absolute;
70 | top: 2rem;
71 | left: 2rem;
72 | right: 2rem;
73 | bottom: 2rem;
74 | padding: 2rem;
75 |
76 | > .anticon {
77 | font-size: 10rem;
78 | text-align: center;
79 | margin-bottom: 2rem;
80 | color: ${props => props.colorType};
81 | }
82 | p {
83 | font-size: 2rem;
84 | font-weight: bold;
85 | margin-bottom: 2rem;
86 | }
87 |
88 | ul {
89 | list-style: none;
90 | padding: 0;
91 | margin-bottom: 3rem;
92 |
93 | li {
94 | display: flex;
95 | line-height: 2;
96 | span:first-child {
97 | width: 8rem;
98 | color: #888;
99 | }
100 | span:last-child {
101 | width: calc(100% - 8rem);
102 | color: #333;
103 | }
104 | }
105 | }
106 |
107 | button, button:hover {
108 | border-color: ${props => props.colorType};
109 | color: ${props => props.colorType};
110 | }
111 | button:hover {
112 | opacity: 0.7;
113 | }
114 | `;
115 |
116 | export default withRouter(CertificationResult);
--------------------------------------------------------------------------------
/src/Home/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { Button, Icon } from 'antd';
4 | import { withRouter } from 'react-router-dom';
5 |
6 | function Home({ history }) {
7 | return (
8 |
9 |
10 |
아임포트 테스트
11 | 아임포트 리액트 테스트 화면입니다.
12 | 아래 버튼을 눌러 결제 또는 본인인증 테스트를 진행해주세요.
13 |
14 |
15 |
16 |
20 |
24 |
25 |
26 | );
27 | }
28 |
29 | const Wrapper = styled.div`
30 | display: flex;
31 | align-items: center;
32 | justify-content: center;
33 | flex-direction: column;
34 | position: absolute;
35 | top: 0;
36 | left: 0;
37 | right: 0;
38 | bottom: 0;
39 |
40 | > div {
41 | position: absolute;
42 | left: 0;
43 | right: 0;
44 | }
45 | > div:first-child {
46 | background-color: #344e81;
47 | top: 0;
48 | bottom: 50%;
49 | display: flex;
50 | align-items: center;
51 | justify-content: center;
52 | flex-direction: column;
53 | > * {
54 | color: #fff;
55 | }
56 |
57 | h4 {
58 | margin: 0;
59 | line-height: 1.5;
60 | }
61 | }
62 | > div:nth-child(2) {
63 | top: 50%;
64 | bottom: 0;
65 | }
66 | `;
67 |
68 | const ButtonContainer = styled.div`
69 | position: absolute;
70 | display: flex;
71 | align-items: center;
72 | justify-content: center;
73 | top: 50%;
74 | margin-top: -5rem;
75 |
76 | button {
77 | display: flex;
78 | align-items: center;
79 | justify-content: center;
80 | flex-direction: column;
81 | height: 10rem;
82 | width: 15rem;
83 | margin: 0 0.5rem;
84 | border: none;
85 | box-shadow: 0 0 1rem 0 rgba(0, 0, 0, 0.13);
86 | .anticon {
87 | margin-bottom: 0.5rem;
88 | font-size: 2rem;
89 | & + span {
90 | margin: 0;
91 | }
92 | }
93 | }
94 | `;
95 |
96 | export default withRouter(Home);
97 |
--------------------------------------------------------------------------------
/src/Payment/constants.js:
--------------------------------------------------------------------------------
1 | export const PGS = [
2 | {
3 | value: 'html5_inicis',
4 | label: '웹 표준 이니시스',
5 | },
6 | {
7 | value: 'kcp',
8 | label: 'NHN KCP',
9 | },
10 | {
11 | value: 'kcp_billing',
12 | label: 'NHN KCP 정기결제',
13 | },
14 | {
15 | value: 'uplus',
16 | label: 'LG 유플러스',
17 | },
18 | {
19 | value: 'jtnet',
20 | label: 'JTNET',
21 | },
22 | {
23 | value: 'nice',
24 | label: '나이스 정보통신',
25 | },
26 | {
27 | value: 'kakaopay',
28 | label: '신 - 카카오페이',
29 | },
30 | {
31 | value: 'kakao',
32 | label: '구 - LG CNS 카카오페이',
33 | },
34 | {
35 | value: 'danal',
36 | label: '다날 휴대폰 소액결제',
37 | },
38 | {
39 | value: 'danal_tpay',
40 | label: '다날 일반결제',
41 | },
42 | {
43 | value: 'kicc',
44 | label: '한국정보통신',
45 | },
46 | {
47 | value: 'paypal',
48 | label: '페이팔',
49 | },
50 | {
51 | value: 'mobilians',
52 | label: '모빌리언스',
53 | },
54 | {
55 | value: 'payco',
56 | label: '페이코',
57 | },
58 | {
59 | value: 'settle',
60 | label: '세틀뱅크 가상계좌',
61 | },
62 | {
63 | value: 'naverco',
64 | label: '네이버 체크아웃',
65 | },
66 | {
67 | value: 'naverpay',
68 | label: '네이버페이',
69 | },
70 | {
71 | value: 'smilepay',
72 | label: '스마일페이',
73 | },
74 | ];
75 |
76 | export const METHODS = [
77 | {
78 | value: 'card',
79 | label: '신용카드',
80 | },
81 | {
82 | value: 'vbank',
83 | label: '가상계좌',
84 | },
85 | {
86 | value: 'trans',
87 | label: '실시간 계좌이체',
88 | },
89 | {
90 | value: 'phone',
91 | label: '휴대폰 소액결제'
92 | },
93 | ];
94 |
95 | export const METHODS_FOR_INICIS =
96 | METHODS.concat([
97 | {
98 | value: 'samsung',
99 | label: '삼성페이',
100 | },
101 | {
102 | value: 'kapy',
103 | label: 'KPAY',
104 | },
105 | {
106 | value: 'cultureland',
107 | label: '문화상품권',
108 | },
109 | {
110 | value: 'smartculture',
111 | label: '스마트문상',
112 | },
113 | {
114 | value: 'happymoney',
115 | label: '해피머니',
116 | },
117 | ]);
118 |
119 | export const METHODS_FOR_UPLUS =
120 | METHODS.concat([
121 | {
122 | value: 'cultureland',
123 | label: '문화상품권',
124 | },
125 | {
126 | value: 'smartculture',
127 | label: '스마트문상',
128 | },
129 | {
130 | value: 'booknlife',
131 | label: '도서상품권',
132 | },
133 | ]);
134 |
135 | export const METHODS_FOR_KCP =
136 | METHODS.concat([
137 | {
138 | value: 'samsung',
139 | label: '삼성페이',
140 | },
141 | ]);
142 |
143 | export const METHODS_FOR_MOBILIANS = [
144 | {
145 | value: 'card',
146 | label: '신용카드',
147 | },
148 | {
149 | value: 'phone',
150 | label: '휴대폰 소액결제',
151 | },
152 | ];
153 |
154 | export const METHODS_FOR_DANAL = [
155 | {
156 | value: 'card',
157 | label: '신용카드',
158 | },
159 | {
160 | value: 'vbank',
161 | label: '가상계좌',
162 | },
163 | {
164 | value: 'trans',
165 | label: '실시간 계좌이체',
166 | },
167 | ];
168 |
169 | export const METHOD_FOR_CARD = [
170 | {
171 | value: 'card',
172 | label: '신용카드',
173 | },
174 | ];
175 |
176 | export const METHOD_FOR_PHONE = [
177 | {
178 | value: 'phone',
179 | label: '휴대폰 소액결제',
180 | },
181 | ];
182 |
183 | export const METHOD_FOR_VBANK = [
184 | {
185 | value: 'vbank',
186 | label: '가상계좌',
187 | },
188 | ];
189 |
190 | export const QUOTAS = [
191 | {
192 | value: 0,
193 | label: 'PG사 기본 제공',
194 | },
195 | {
196 | value: 1,
197 | label: '일시불',
198 | },
199 | ];
200 |
201 | export const QUOTAS_FOR_INICIS_AND_KCP = [
202 | {
203 | value: 0,
204 | label: 'PG사 기본 제공',
205 | },
206 | {
207 | value: 1,
208 | label: '일시불',
209 | },
210 | {
211 | value: 2,
212 | label: '2개월',
213 | },
214 | {
215 | value: 3,
216 | label: '3개월',
217 | },
218 | {
219 | value: 4,
220 | label: '4개월',
221 | },
222 | {
223 | value: 5,
224 | label: '5개월',
225 | },
226 | {
227 | value: 6,
228 | label: '6개월',
229 | },
230 | ];
231 |
--------------------------------------------------------------------------------
/src/Payment/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import styled from 'styled-components';
3 | import { Form, Select, Icon, Input, Switch, Button } from 'antd';
4 | import { withRouter } from 'react-router-dom';
5 | import { withUserAgent } from 'react-useragent';
6 | import queryString from 'query-string';
7 |
8 | import {
9 | PGS,
10 | METHODS_FOR_INICIS,
11 | QUOTAS_FOR_INICIS_AND_KCP,
12 | } from './constants';
13 | import { getMethods, getQuotas } from './utils';
14 |
15 | const { Item } = Form;
16 | const { Option } = Select;
17 |
18 | function Payment({ history, form, ua }) {
19 | const [methods, setMethods] = useState(METHODS_FOR_INICIS);
20 | const [quotas, setQuotas] = useState(QUOTAS_FOR_INICIS_AND_KCP);
21 | const [isQuotaRequired, setIsQuotaRequired] = useState(true);
22 | const [isDigitalRequired, setIsDigitalRequired] = useState(false);
23 | const [isVbankDueRequired, setIsVbankDueRequired] = useState(false);
24 | const [isBizNumRequired, setisBizNumRequired] = useState(false);
25 | const { getFieldDecorator, validateFieldsAndScroll, setFieldsValue, getFieldsValue } = form;
26 |
27 | function handleSubmit(e) {
28 | e.preventDefault();
29 |
30 | validateFieldsAndScroll((error, values) => {
31 | if (!error) {
32 | /* 가맹점 식별코드 */
33 | const userCode = 'imp19424728';
34 | /* 결제 데이터 */
35 | const {
36 | pg,
37 | pay_method,
38 | merchant_uid,
39 | name,
40 | amount,
41 | buyer_name,
42 | buyer_tel,
43 | buyer_email,
44 | escrow,
45 | card_quota,
46 | biz_num,
47 | vbank_due,
48 | digital,
49 | } = values;
50 |
51 | const data = {
52 | pg,
53 | pay_method,
54 | merchant_uid,
55 | name,
56 | amount,
57 | buyer_name,
58 | buyer_tel,
59 | buyer_email,
60 | escrow,
61 | };
62 |
63 | if (pay_method === 'vbank') {
64 | data.vbank_due = vbank_due;
65 | if (pg === 'danal_tpay') {
66 | data.biz_num = biz_num;
67 | }
68 | }
69 | if (pay_method === 'card') {
70 | if (card_quota !== 0) {
71 | data.digital = { card_quota: card_quota === 1 ? [] : card_quota };
72 | }
73 | }
74 | if (pay_method === 'phone') {
75 | data.digital = digital;
76 | }
77 |
78 | if (isReactNative()) {
79 | /* 리액트 네이티브 환경일때 */
80 | const params = {
81 | userCode,
82 | data,
83 | type: 'payment', // 결제와 본인인증을 구분하기 위한 필드
84 | };
85 | const paramsToString = JSON.stringify(params);
86 | window.ReactNativeWebView.postMessage(paramsToString);
87 | } else {
88 | /* 웹 환경일때 */
89 | const { IMP } = window;
90 | IMP.init(userCode);
91 | IMP.request_pay(data, callback);
92 | }
93 | }
94 | });
95 | }
96 |
97 | function callback(response) {
98 | const query = queryString.stringify(response);
99 | history.push(`/payment/result?${query}`);
100 | }
101 |
102 | function onChangePg(value) {
103 | /* 결제수단 */
104 | const methods = getMethods(value);
105 | setMethods(methods);
106 | setFieldsValue({ pay_method: methods[0].value })
107 |
108 | /* 할부개월수 설정 */
109 | const { pay_method } = getFieldsValue();
110 | handleQuotas(value, pay_method);
111 |
112 | /* 사업자번호/입금기한 설정 */
113 | let isBizNumRequired = false;
114 | let isVbankDueRequired = false;
115 | if (pay_method === 'vbank') {
116 | if (value === 'danal_tpay') {
117 | isBizNumRequired = true;
118 | }
119 | isVbankDueRequired = true;
120 | }
121 | setisBizNumRequired(isBizNumRequired);
122 | setIsVbankDueRequired(isVbankDueRequired);
123 | }
124 |
125 | function onChangePayMethod(value) {
126 | const { pg } = getFieldsValue();
127 | let isQuotaRequired = false;
128 | let isDigitalRequired = false;
129 | let isVbankDueRequired = false;
130 | let isBizNumRequired = false;
131 | switch (value) {
132 | case 'card': {
133 | isQuotaRequired = true;
134 | break;
135 | }
136 | case 'phone': {
137 | isDigitalRequired = true;
138 | break;
139 | }
140 | case 'vbank': {
141 | if (pg === 'danal_tpay') {
142 | isBizNumRequired = true;
143 | }
144 | isVbankDueRequired = true;
145 | break;
146 | }
147 | default:
148 | break;
149 | }
150 | setIsQuotaRequired(isQuotaRequired);
151 | setIsDigitalRequired(isDigitalRequired);
152 | setIsVbankDueRequired(isVbankDueRequired);
153 | setisBizNumRequired(isBizNumRequired);
154 |
155 | /* 할부개월수 설정 */
156 | handleQuotas(pg, value);
157 | }
158 |
159 | function handleQuotas(pg, pay_method) {
160 | const { isQuotaRequired, quotas } = getQuotas(pg, pay_method);
161 | setIsQuotaRequired(isQuotaRequired);
162 | setQuotas(quotas);
163 | setFieldsValue({ card_quota: quotas[0].value })
164 | }
165 |
166 | function isReactNative() {
167 | /*
168 | 리액트 네이티브 환경인지 여부를 판단해
169 | 리액트 네이티브의 경우 IMP.payment()를 호출하는 대신
170 | iamport-react-native 모듈로 post message를 보낸다
171 |
172 | 아래 예시는 모든 모바일 환경을 리액트 네이티브로 인식한 것으로
173 | 실제로는 user agent에 값을 추가해 정확히 판단해야 한다
174 | */
175 | if (ua.mobile) return true;
176 | return false;
177 | }
178 |
179 | return (
180 |
181 |
182 |
183 | -
184 | {getFieldDecorator('pg', {
185 | initialValue: 'html5_inicis',
186 | })(
187 | }
191 | >
192 | {PGS.map(pg => {
193 | const { value, label } = pg;
194 | return ;
195 | })}
196 |
197 | )}
198 |
199 | -
200 | {getFieldDecorator('pay_method', {
201 | initialValue: 'card',
202 | })(
203 | }
207 | >
208 | {methods.map(method => {
209 | const { value, label } = method;
210 | return ;
211 | })}
212 |
213 | )}
214 |
215 | {isQuotaRequired && (
216 | -
217 | {getFieldDecorator('card_quota', {
218 | initialValue: 0,
219 | })(
220 | }>
221 | {quotas.map(quota => {
222 | const { value, label } = quota;
223 | return ;
224 | })}
225 |
226 | )}
227 |
228 | )}
229 | {isVbankDueRequired && (-
230 | {getFieldDecorator('vbank_due', {
231 | rules: [{ required: true, message: '입금기한은 필수입력입니다' }],
232 | })(
233 | ,
234 | )}
235 |
)}
236 | {isBizNumRequired && (
237 | -
238 | {getFieldDecorator('biz_num', {
239 | rules: [{ required: true, message: '사업자번호는 필수입력입니다' }],
240 | })(
241 | ,
242 | )}
243 |
244 | )}
245 | {isDigitalRequired && (
246 | -
247 | {getFieldDecorator('digital', {
248 | valuePropName: 'checked',
249 | })()}
250 |
251 | )}
252 | -
253 | {getFieldDecorator('escrow', {
254 | valuePropName: 'checked',
255 | })()}
256 |
257 | -
258 | {getFieldDecorator('name', {
259 | initialValue: '아임포트 결제 데이터 분석',
260 | rules: [{ required: true, message: '주문명은 필수입력입니다' }],
261 | })(
262 | ,
263 | )}
264 |
265 | -
266 | {getFieldDecorator('amount', {
267 | initialValue: '39000',
268 | rules: [{ required: true, message: '결제금액은 필수입력입니다' }],
269 | })(
270 | ,
271 | )}
272 |
273 | -
274 | {getFieldDecorator('merchant_uid', {
275 | initialValue: `min_${new Date().getTime()}`,
276 | rules: [{ required: true, message: '주문번호는 필수입력입니다' }],
277 | })(
278 | ,
279 | )}
280 |
281 | -
282 | {getFieldDecorator('buyer_name', {
283 | initialValue: '홍길동',
284 | rules: [{ required: true, message: '구매자 이름은 필수입력입니다' }],
285 | })(
286 | ,
287 | )}
288 |
289 | -
290 | {getFieldDecorator('buyer_tel', {
291 | initialValue: '01012341234',
292 | rules: [{ required: true, message: '구매자 전화번호는 필수입력입니다' }],
293 | })(
294 | ,
295 | )}
296 |
297 | -
298 | {getFieldDecorator('buyer_email', {
299 | initialValue: 'example@example.com',
300 | rules: [{ required: true, message: '구매자 이메일은 필수입력입니다' }],
301 | })(
302 | ,
303 | )}
304 |
305 |
308 |
309 |
310 | );
311 | }
312 |
313 | const Wrapper = styled.div`
314 | padding: 5rem 0;
315 | display: flex;
316 | align-items: center;
317 | justify-content: center;
318 | flex-direction: column;
319 | `;
320 |
321 | const Header = styled.div`
322 | font-weight: bold;
323 | text-align: center;
324 | padding: 2rem;
325 | padding-top: 0;
326 | font-size: 3rem;
327 | `;
328 |
329 | const FormContainer = styled(Form)`
330 | width: 350px;
331 | border-radius: 3px;
332 |
333 | .ant-row {
334 | margin-bottom: 1rem;
335 | }
336 | .ant-form-item {
337 | display: flex;
338 | align-items: center;
339 | }
340 | .ant-col.ant-form-item-label {
341 | padding: 0 11px;
342 | width: 9rem;
343 | text-align: left;
344 | label {
345 | color: #888;
346 | font-size: 1.2rem;
347 | }
348 | & + .ant-col.ant-form-item-control-wrapper {
349 | width: 26rem;
350 | .ant-form-item-control {
351 | line-height: inherit;
352 | }
353 | }
354 | }
355 | .ant-col.ant-form-item-label > label::after {
356 | display: none;
357 | }
358 | .ant-row.ant-form-item.toggle-container .ant-form-item-control {
359 | padding: 0 11px;
360 | height: 4rem;
361 | display: flex;
362 | align-items: center;
363 | .ant-switch {
364 | margin: 0;
365 | }
366 | }
367 |
368 | .ant-form-explain {
369 | margin-top: 0.5rem;
370 | margin-left: 9rem;
371 | }
372 |
373 | .ant-input-group-addon:first-child {
374 | width: 9rem;
375 | text-align: left;
376 | color: #888;
377 | font-size: 1.2rem;
378 | border: none;
379 | background-color: inherit;
380 | }
381 | .ant-input-group > .ant-input:last-child {
382 | border-radius: 4px;
383 | }
384 |
385 | .ant-col {
386 | width: 100%;
387 | }
388 |
389 | button[type='submit'] {
390 | width: 100%;
391 | height: 5rem;
392 | font-size: 1.6rem;
393 | margin-top: 2rem;
394 | }
395 | `;
396 |
397 | const PaymentForm = Form.create({ name: 'payment' })(Payment);
398 |
399 | export default withUserAgent(withRouter(PaymentForm));
400 |
--------------------------------------------------------------------------------
/src/Payment/utils.js:
--------------------------------------------------------------------------------
1 | import {
2 | METHODS,
3 | METHOD_FOR_CARD,
4 | METHOD_FOR_PHONE,
5 | METHOD_FOR_VBANK,
6 | METHODS_FOR_INICIS,
7 | METHODS_FOR_UPLUS,
8 | METHODS_FOR_KCP,
9 | METHODS_FOR_MOBILIANS,
10 | METHODS_FOR_DANAL,
11 | QUOTAS,
12 | QUOTAS_FOR_INICIS_AND_KCP,
13 | } from './constants';
14 |
15 | export function getMethods(pg) {
16 | switch (pg) {
17 | case 'html5_inicis': return METHODS_FOR_INICIS;
18 | case 'kcp': return METHODS_FOR_KCP;
19 | case 'uplus': return METHODS_FOR_UPLUS;
20 | case 'kcp_billing':
21 | case 'kakaopay':
22 | case 'kakao':
23 | case 'paypal':
24 | case 'smilepay':
25 | return METHOD_FOR_CARD;
26 | case 'danal':
27 | return METHOD_FOR_PHONE;
28 | case 'danal_tpay':
29 | return METHODS_FOR_DANAL;
30 | case 'mobilians':
31 | return METHODS_FOR_MOBILIANS;
32 | case 'settle':
33 | return METHOD_FOR_VBANK;
34 | default: return METHODS;
35 | }
36 | }
37 |
38 | export function getQuotas(pg, method) {
39 | if (method === 'card') {
40 | switch (pg) {
41 | case 'html5_inicis':
42 | case 'kcp':
43 | return { isQuotaRequired: true, quotas: QUOTAS_FOR_INICIS_AND_KCP };
44 | default:
45 | return { isQuotaRequired: true, quotas: QUOTAS };
46 | }
47 | }
48 | return { isQuotaRequired: false, quotas: QUOTAS };
49 | }
--------------------------------------------------------------------------------
/src/PaymentResult/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { Icon, Button } from 'antd';
4 | import { withRouter } from 'react-router-dom';
5 | import queryString from 'query-string';
6 |
7 | function PaymentResult({ history }) {
8 | const { location } = history;
9 | const { search } = location;
10 | const query = queryString.parse(search);
11 |
12 | const { merchant_uid, error_msg, imp_uid } = query;
13 | const isSuccessed = getIsSuccessed();
14 | function getIsSuccessed() {
15 | const { success, imp_success } = query;
16 | if (typeof imp_success === 'string') return imp_success === 'true';
17 | if (typeof imp_success === 'boolean') return imp_success === true;
18 | if (typeof success === 'string') return success === 'true';
19 | if (typeof success === 'boolean') return success === true;
20 | }
21 |
22 | const iconType = isSuccessed ? 'check-circle' : 'exclamation-circle';
23 | const resultType = isSuccessed ? '성공' : '실패';
24 | const colorType = isSuccessed ? '#52c41a' : '#f5222d';
25 | return (
26 |
27 |
28 |
29 | {`결제에 ${resultType}하였습니다`}
30 |
31 | -
32 | 주문번호
33 | {merchant_uid}
34 |
35 | {isSuccessed ? (
36 | -
37 | 아임포트 번호
38 | {imp_uid}
39 |
40 | ) : (
41 | -
42 | 에러 메시지
43 | {error_msg}
44 |
45 | )}
46 |
47 |
51 |
52 |
53 | );
54 | }
55 |
56 | const Wrapper = styled.div`
57 | position: absolute;
58 | top: 0;
59 | left: 0;
60 | right: 0;
61 | bottom: 0;
62 | `;
63 |
64 | const Container = styled.div`
65 | display: flex;
66 | align-items: center;
67 | justify-content: center;
68 | flex-direction: column;
69 | background-color: #fff;
70 | border-radius: 4px;
71 | position: absolute;
72 | top: 2rem;
73 | left: 2rem;
74 | right: 2rem;
75 | bottom: 2rem;
76 | padding: 2rem;
77 |
78 | > .anticon {
79 | font-size: 10rem;
80 | text-align: center;
81 | margin-bottom: 2rem;
82 | color: ${props => props.colorType};
83 | }
84 | p {
85 | font-size: 2rem;
86 | font-weight: bold;
87 | margin-bottom: 2rem;
88 | }
89 |
90 | ul {
91 | list-style: none;
92 | padding: 0;
93 | margin-bottom: 3rem;
94 |
95 | li {
96 | display: flex;
97 | line-height: 2;
98 | span:first-child {
99 | width: 8rem;
100 | color: #888;
101 | }
102 | span:last-child {
103 | width: calc(100% - 8rem);
104 | color: #333;
105 | }
106 | }
107 | }
108 |
109 | button, button:hover {
110 | border-color: ${props => props.colorType};
111 | color: ${props => props.colorType};
112 | }
113 | button:hover {
114 | opacity: 0.7;
115 | }
116 | `;
117 |
118 | export default withRouter(PaymentResult);
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | font-size: 10px;
3 | }
4 |
5 | body {
6 | margin: 0;
7 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
8 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
9 | sans-serif;
10 | -webkit-font-smoothing: antialiased;
11 | -moz-osx-font-smoothing: grayscale;
12 | }
13 |
14 | code {
15 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
16 | monospace;
17 | }
18 |
19 | input[type=number]::-webkit-inner-spin-button,
20 | input[type=number]::-webkit-outer-spin-button {
21 | -webkit-appearance: none;
22 | margin: 0;
23 | }
24 |
25 | #root {
26 | background-color: #f5f5f5;
27 | min-height: 100%;
28 | }
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 | import * as serviceWorker from './serviceWorker';
6 |
7 | ReactDOM.render(, document.getElementById('root'));
8 |
9 | // If you want your app to work offline and load faster, you can change
10 | // unregister() to register() below. Note this comes with some pitfalls.
11 | // Learn more about service workers: https://bit.ly/CRA-PWA
12 | serviceWorker.unregister();
13 |
--------------------------------------------------------------------------------
/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.1/8 is considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener('load', () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | 'This web app is being served cache-first by a service ' +
46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | 'New content is available and will be used when all ' +
74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log('Content is cached for offline use.');
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error('Error during service worker registration:', error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl)
104 | .then(response => {
105 | // Ensure service worker exists, and that we really are getting a JS file.
106 | const contentType = response.headers.get('content-type');
107 | if (
108 | response.status === 404 ||
109 | (contentType != null && contentType.indexOf('javascript') === -1)
110 | ) {
111 | // No service worker found. Probably a different app. Reload the page.
112 | navigator.serviceWorker.ready.then(registration => {
113 | registration.unregister().then(() => {
114 | window.location.reload();
115 | });
116 | });
117 | } else {
118 | // Service worker found. Proceed as normal.
119 | registerValidSW(swUrl, config);
120 | }
121 | })
122 | .catch(() => {
123 | console.log(
124 | 'No internet connection found. App is running in offline mode.'
125 | );
126 | });
127 | }
128 |
129 | export function unregister() {
130 | if ('serviceWorker' in navigator) {
131 | navigator.serviceWorker.ready.then(registration => {
132 | registration.unregister();
133 | });
134 | }
135 | }
136 |
--------------------------------------------------------------------------------