Skip to content

Commit 6a257fd

Browse files
committedJan 20, 2025
[Feat] 쿠팡 장바구니 담기 + 결제 구현
1 parent 3c001c9 commit 6a257fd

23 files changed

+2806
-10
lines changed
 

‎.gitignore

+5
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,8 @@ yarn-error.log*
3636
next-env.d.ts
3737

3838
*storybook.log
39+
node_modules/
40+
/test-results/
41+
/playwright-report/
42+
/blob-report/
43+
/playwright/.cache/

‎actions/11street/11streetActions.ts

+149
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
'use server';
2+
3+
import SessionBrowserManager from '@/hooks/sessionBrowserManager';
4+
5+
const BASE_URL = 'https://www.11st.co.kr/main';
6+
const DETAIL_BASE_URL = 'https://www.coupang.com/vp/products/';
7+
8+
function delay(ms: number): Promise<void> {
9+
return new Promise((resolve) => setTimeout(resolve, ms));
10+
}
11+
12+
export const coupangSignIn = async () => {
13+
const { page, status } = await SessionBrowserManager.getInstance();
14+
15+
if (status !== 'NOT_SIGNIN') {
16+
return;
17+
}
18+
19+
await page.goto(BASE_URL);
20+
await delay(Math.random() * 4000 + 2000);
21+
22+
await page.locator('.login').click();
23+
await delay(Math.random() * 4000 + 2000);
24+
25+
const coupangId = process.env.COUPANG_ID;
26+
const coupangPw = process.env.COUPANG_PW;
27+
await page.locator('._loginIdInput').fill(coupangId ?? '');
28+
await delay(Math.random() * 4000 + 2000);
29+
30+
await page.locator('._loginPasswordInput').fill(coupangPw ?? '');
31+
await delay(Math.random() * 3000 + 4000);
32+
33+
await page.locator('.login__button--submit').click();
34+
await delay(Math.random() * 3000 + 4000);
35+
};
36+
37+
export const coupangGetPincode = async () => {
38+
const sessionBrowserManager = await SessionBrowserManager.getInstance();
39+
const { page, status } = sessionBrowserManager;
40+
41+
if (status !== 'NOT_SIGNIN') {
42+
return false;
43+
}
44+
45+
const isPresent =
46+
(await page.locator('.pincode-content__button').count()) > 0;
47+
48+
if (!isPresent) {
49+
return false;
50+
}
51+
52+
await page.locator('.pincode-content__button').click();
53+
await delay(Math.random() * 4000 + 2000);
54+
55+
if (isPresent) {
56+
sessionBrowserManager.status = 'PINCODE';
57+
return true;
58+
}
59+
};
60+
61+
export const coupangSetPincode = async (pincode: string) => {
62+
console.log('🚀 ~ coupangSetPincode ~ pincode:', pincode);
63+
const sessionBrowserManager = await SessionBrowserManager.getInstance();
64+
const { page, status } = sessionBrowserManager;
65+
66+
if (status !== 'PINCODE') {
67+
if (status === 'NOT_SIGNIN') {
68+
sessionBrowserManager.status = 'SIGNIN';
69+
}
70+
return;
71+
}
72+
73+
await page
74+
.locator('.pincode-input__pincode-input-box__pincode')
75+
.fill(pincode);
76+
await delay(Math.random() * 4000 + 2000);
77+
78+
await page.locator('.pincode-input__button').click();
79+
await delay(Math.random() * 4000 + 2000);
80+
sessionBrowserManager.status = 'SIGNIN';
81+
};
82+
83+
export const coupangAddCart = async ({
84+
pid,
85+
quantity,
86+
}: {
87+
pid: string;
88+
quantity: number;
89+
}) => {
90+
const { page, status } = await SessionBrowserManager.getInstance();
91+
92+
if (status !== 'SIGNIN') {
93+
return;
94+
}
95+
console.log('Add Cart Function!! ~ ', status);
96+
await page.goto(DETAIL_BASE_URL + pid);
97+
await delay(Math.random() * 4000 + 2000);
98+
99+
await page.locator('.prod-quantity__input').fill(quantity.toString());
100+
await delay(Math.random() * 4000 + 2000);
101+
102+
await page.locator('.prod-cart-btn').click();
103+
await delay(Math.random() * 5000 + 3000);
104+
};
105+
106+
export const coupangPayAll = async () => {
107+
const sessionBrowserManager = await SessionBrowserManager.getInstance();
108+
const { page, status } = sessionBrowserManager;
109+
110+
if (status !== 'SIGNIN') {
111+
return;
112+
}
113+
console.log('Pay All Function!!');
114+
115+
await page.goto(BASE_URL);
116+
await delay(Math.random() * 4000 + 2000);
117+
await page.locator('.mycart-preview-module').click();
118+
await delay(Math.random() * 4000 + 2000);
119+
await page.locator('.order-buttons').click();
120+
await delay(Math.random() * 4000 + 2000);
121+
await page.locator('.paymentBtn-v2-style').click();
122+
await delay(Math.random() * 4000 + 2000);
123+
124+
await delay(Math.random() * 6000 + 2000);
125+
const screenshotBuffer = await page
126+
.locator('#modal-callLGPayment')
127+
.screenshot();
128+
await delay(Math.random() * 4000 + 2000);
129+
130+
sessionBrowserManager.status = 'PAYMENT';
131+
return screenshotBuffer;
132+
};
133+
134+
export const coupangInsertPassword = async (password: string) => {
135+
console.log('🚀 ~ coupangPayment');
136+
137+
const sessionBrowserManager = await SessionBrowserManager.getInstance();
138+
const { page, status } = sessionBrowserManager;
139+
140+
if (status != 'PAYMENT') {
141+
return;
142+
}
143+
144+
const iframe = page.frameLocator('#callLGPayment');
145+
for (const numpad of password) {
146+
await iframe.locator(`[data-key="${numpad}"]`).click();
147+
await delay(Math.random() * 1000 + 2000);
148+
}
149+
};

‎actions/coupang/coupangActions.ts

+168
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
'use server';
2+
3+
import { Item } from '@/app/api/coupang/route';
4+
import SessionBrowserManager from '@/hooks/sessionBrowserManager';
5+
6+
const BASE_URL = 'https://www.coupang.com/';
7+
const DETAIL_BASE_URL = 'https://www.coupang.com/vp/products/';
8+
9+
const DELAY = 2000;
10+
const RANGE = 2000;
11+
12+
function delay(ms: number): Promise<void> {
13+
return new Promise((resolve) => setTimeout(resolve, ms));
14+
}
15+
16+
export const coupangSignIn = async () => {
17+
const { page, status } = await SessionBrowserManager.getInstance();
18+
19+
if (status !== 'NOT_SIGNIN') {
20+
return;
21+
}
22+
23+
await page.goto(BASE_URL);
24+
console.log('Go to Main');
25+
await delay(Math.random() * DELAY + RANGE);
26+
27+
await page.locator('.login').click();
28+
console.log('Login Button Clicked!');
29+
await delay(Math.random() * DELAY + RANGE);
30+
31+
const coupangId = process.env.COUPANG_ID;
32+
const coupangPw = process.env.COUPANG_PW;
33+
await page.locator('._loginIdInput').fill(coupangId ?? '');
34+
console.log('Login ID Filled!');
35+
await delay(Math.random() * DELAY + RANGE);
36+
37+
await page.locator('._loginPasswordInput').fill(coupangPw ?? '');
38+
console.log('Login PWD Filled!');
39+
await delay(Math.random() * DELAY + RANGE);
40+
41+
await page.locator('.login__button--submit').click();
42+
console.log('Login Button Clicked!');
43+
await delay(Math.random() * DELAY + RANGE);
44+
};
45+
46+
export const coupangGetPincode = async () => {
47+
const sessionBrowserManager = await SessionBrowserManager.getInstance();
48+
const { page, status } = sessionBrowserManager;
49+
50+
if (status !== 'NOT_SIGNIN') {
51+
return false;
52+
}
53+
54+
const isPresent =
55+
(await page.locator('.pincode-content__button').count()) > 0;
56+
57+
if (!isPresent) {
58+
return false;
59+
}
60+
61+
await page.locator('.pincode-content__button').click();
62+
await delay(Math.random() * DELAY + RANGE);
63+
64+
if (isPresent) {
65+
sessionBrowserManager.status = 'PINCODE';
66+
return true;
67+
}
68+
};
69+
70+
export const coupangSetPincode = async (pincode: string) => {
71+
console.log('🚀 ~ coupangSetPincode ~ pincode:', pincode);
72+
const sessionBrowserManager = await SessionBrowserManager.getInstance();
73+
const { page, status } = sessionBrowserManager;
74+
75+
if (status !== 'PINCODE') {
76+
if (status === 'NOT_SIGNIN') {
77+
sessionBrowserManager.status = 'SIGNIN';
78+
}
79+
return;
80+
}
81+
82+
await page
83+
.locator('.pincode-input__pincode-input-box__pincode')
84+
.fill(pincode);
85+
await delay(Math.random() * DELAY + RANGE);
86+
87+
await page.locator('.pincode-input__button').click();
88+
await delay(Math.random() * DELAY + RANGE);
89+
sessionBrowserManager.status = 'SIGNIN';
90+
};
91+
92+
export const coupangAddCart = async ({ productId, itemId, quantity }: Item) => {
93+
const { page, status } = await SessionBrowserManager.getInstance();
94+
95+
if (status !== 'SIGNIN') {
96+
return;
97+
}
98+
console.log('Add Cart Function!! ~ ', status);
99+
const url = itemId
100+
? DETAIL_BASE_URL + productId + '?vendorItemId=' + itemId
101+
: DETAIL_BASE_URL + productId;
102+
await page.goto(url);
103+
await delay(Math.random() * DELAY + RANGE);
104+
105+
await page.locator('.prod-quantity__input').fill(quantity.toString());
106+
console.log('Product Quantity Set');
107+
await delay(Math.random() * DELAY + RANGE);
108+
109+
await page.locator('.prod-cart-btn').click();
110+
console.log('Product Cart Button Clicked!');
111+
await delay(Math.random() * DELAY + RANGE);
112+
};
113+
114+
export const coupangPayAll = async () => {
115+
const sessionBrowserManager = await SessionBrowserManager.getInstance();
116+
const { page, status } = sessionBrowserManager;
117+
118+
if (status !== 'SIGNIN') {
119+
return;
120+
}
121+
console.log('Pay All Function!!');
122+
123+
await page.goto(BASE_URL);
124+
await delay(Math.random() * DELAY + RANGE);
125+
126+
await page.locator('.mycart-preview-module').click();
127+
console.log('MyCart Button Clicked!');
128+
await delay(Math.random() * DELAY + RANGE);
129+
130+
await page.locator('.order-buttons').click();
131+
console.log('Order Button Clicked!');
132+
await delay(Math.random() * DELAY + RANGE);
133+
134+
await page.locator('.paymentBtn-v2-style').click();
135+
console.log('Payment Button Clicked!');
136+
await delay(Math.random() * DELAY + RANGE);
137+
138+
await delay(Math.random() * (DELAY + 2000) + RANGE);
139+
const screenshotBuffer = await page
140+
.locator('#modal-callLGPayment')
141+
.screenshot();
142+
143+
sessionBrowserManager.status = 'PAYMENT';
144+
return screenshotBuffer;
145+
};
146+
147+
export const coupangInsertPassword = async (password: string) => {
148+
const sessionBrowserManager = await SessionBrowserManager.getInstance();
149+
const { page, status } = sessionBrowserManager;
150+
151+
console.log('🚀 ~ coupangPayment ~ ', status);
152+
153+
if (status !== 'PAYMENT') {
154+
return;
155+
}
156+
157+
const iframe = page.frameLocator('#callLGPayment');
158+
for (const numpad of password) {
159+
await iframe.locator(`[data-key="${numpad}"]`).click();
160+
console.log('numpad ', numpad, 'clicked!');
161+
await delay(Math.random() * 1000 + RANGE);
162+
}
163+
return;
164+
};
165+
166+
export const coupangClose = async () => {
167+
await SessionBrowserManager.close();
168+
};

‎actions/playwrightActions.ts

+155
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import SessionBrowserManager, {
2+
BrowserStatus,
3+
} from '@/hooks/sessionBrowserManager';
4+
import { Page } from 'playwright';
5+
6+
export type ActionType = 'GOTO' | 'FILL' | 'CLICK' | 'CHECK';
7+
8+
type PlaywrightGotoAction = {
9+
actionType: 'GOTO';
10+
url: string;
11+
};
12+
13+
type PlaywrightClickAction = {
14+
actionType: 'CLICK';
15+
selector: string;
16+
};
17+
18+
type PlaywrightFillAction = {
19+
actionType: 'FILL';
20+
selector: string;
21+
content: string;
22+
};
23+
24+
type PlaywrightCheckAction = {
25+
actionType: 'CHECK';
26+
selector: string;
27+
};
28+
29+
export type PlaywrightAction = {
30+
action:
31+
| PlaywrightGotoAction
32+
| PlaywrightClickAction
33+
| PlaywrightFillAction
34+
| PlaywrightCheckAction;
35+
time?: number;
36+
range?: number;
37+
};
38+
39+
const DELAY = 4000;
40+
const RANGE = 2000;
41+
42+
function delay(ms: number): Promise<void> {
43+
return new Promise((resolve) => setTimeout(resolve, ms));
44+
}
45+
46+
export async function excutePlaywrightActions({
47+
playwrightActions,
48+
expectStatus,
49+
}: {
50+
playwrightActions: PlaywrightAction[];
51+
expectStatus?: BrowserStatus;
52+
}) {
53+
const { page, status } = await SessionBrowserManager.getInstance();
54+
if (expectStatus && status !== expectStatus) {
55+
return;
56+
}
57+
58+
for (const playwrightAction of playwrightActions) {
59+
await excutePlaywrightAction(playwrightAction, page);
60+
}
61+
}
62+
63+
export async function excutePlaywrightAction(
64+
{ action, time = DELAY, range = RANGE }: PlaywrightAction,
65+
page: Page
66+
) {
67+
const { actionType } = action;
68+
switch (actionType) {
69+
case 'GOTO': {
70+
const { url } = action;
71+
await page.goto(url);
72+
await delay(Math.random() * time + range);
73+
break;
74+
}
75+
case 'CLICK': {
76+
const { selector } = action;
77+
await page.locator(selector).click();
78+
await delay(Math.random() * time + range);
79+
break;
80+
}
81+
case 'FILL': {
82+
const { selector, content } = action;
83+
await page.locator(selector).fill(content);
84+
await delay(Math.random() * time + range);
85+
break;
86+
}
87+
}
88+
}
89+
90+
export async function playwrightGoto({
91+
page,
92+
url,
93+
time = DELAY,
94+
range = RANGE,
95+
}: {
96+
page: Page;
97+
url: string;
98+
time?: number;
99+
range?: number;
100+
}) {
101+
await page.goto(url);
102+
await delay(Math.random() * time + range);
103+
}
104+
105+
export async function playwrightClick({
106+
page,
107+
selector,
108+
time = DELAY,
109+
range = RANGE,
110+
}: {
111+
page: Page;
112+
selector: string;
113+
time?: number;
114+
range?: number;
115+
}) {
116+
await page.locator(selector).click();
117+
await delay(Math.random() * time + range);
118+
}
119+
120+
export async function playwrightFill({
121+
page,
122+
selector,
123+
content,
124+
time = DELAY,
125+
range = RANGE,
126+
}: {
127+
page: Page;
128+
selector: string;
129+
content: string;
130+
time?: number;
131+
range?: number;
132+
}) {
133+
await page.locator(selector).fill(content);
134+
await delay(Math.random() * time + range);
135+
}
136+
137+
export async function playwrightCheck({
138+
page,
139+
selector,
140+
}: {
141+
page: Page;
142+
selector: string;
143+
}) {
144+
return (await page.locator(selector).count()) > 0;
145+
}
146+
147+
export async function playwrightScreenShot({
148+
page,
149+
selector,
150+
}: {
151+
page: Page;
152+
selector: string;
153+
}) {
154+
return await page.locator(selector).screenshot();
155+
}

‎app/(head)/accounts/payments/page.tsx

+168-7
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,181 @@
11
'use client';
22

3+
import { NaverPaymentList } from '@/app/api/naverpay/route';
34
import { Badge } from '@/components/ui/badge';
45
import { Button } from '@/components/ui/button';
6+
import { Separator } from '@/components/ui/separator';
7+
import { useRef, useState } from 'react';
58

69
export default function Payments() {
10+
const [selectedFile, setSelectedFile] = useState<File | null>(null);
11+
const [fileContent, setFileContent] = useState<string | null>(null);
12+
const [serverData, setServerData] = useState<NaverPaymentList | null>(null);
13+
14+
const cookieRef = useRef<HTMLInputElement>(null);
15+
16+
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
17+
if (event.target.files && event.target.files[0]) {
18+
setSelectedFile(event.target.files[0]);
19+
}
20+
};
21+
22+
const handleFileUpload = async (event: React.FormEvent) => {
23+
event.preventDefault();
24+
25+
if (!selectedFile) {
26+
alert('파일을 선택하세요!');
27+
return;
28+
}
29+
30+
console.log('파일: ', selectedFile);
31+
32+
const reader = new FileReader();
33+
34+
// 파일 읽기 완료 시 호출
35+
reader.onload = (e) => {
36+
setFileContent(e.target?.result as string);
37+
};
38+
39+
// 텍스트 파일로 읽기
40+
reader.readAsText(selectedFile);
41+
42+
// const formData = new FormData();
43+
// formData.append('file', selectedFile);
44+
45+
// try {
46+
// const response = await fetch('/api/upload', {
47+
// method: 'POST',
48+
// body: formData,
49+
// });
50+
51+
// if (response.ok) {
52+
// setUploadStatus('업로드 성공!');
53+
// } else {
54+
// setUploadStatus('업로드 실패!');
55+
// }
56+
// } catch (error) {
57+
// console.error('Error uploading file:', error);
58+
// setUploadStatus('업로드 중 오류 발생!');
59+
// }
60+
};
61+
62+
const bookmarkletCode = `
63+
javascript:(function() {
64+
navigator.clipboard.writeText(document.cookie)
65+
.then(() => alert('쿠키가 복사되었습니다!'))
66+
.catch(() => alert('복사 실패! 수동으로 복사하세요: ' + document.cookie));
67+
})();
68+
`;
69+
70+
const openWindow = (url: string) => {
71+
const openUrl = url.includes('https') ? url : 'https://' + url;
72+
window.open(openUrl, '_blank');
73+
};
74+
75+
// const openWindows = (urls: string[]) => {
76+
// urls.forEach((url) => openWindow(url));
77+
// };
78+
79+
const handleInput = async () => {
80+
try {
81+
// 클라이언트 데이터를 서버 API로 POST 요청
82+
const response = await fetch('/api/naverpay', {
83+
method: 'POST',
84+
headers: {
85+
'Content-Type': 'application/json',
86+
},
87+
body: JSON.stringify({
88+
url: 'https://new-m.pay.naver.com/api/timeline/v2/search?page=1&requestUrl=https:%2F%2Fnew-m.pay.naver.com%2Fhistorybenefit%2Fpaymenthistory%3Fpage%3D1&from=MOBILE_PAYMENT_HISTORY',
89+
cookie: cookieRef.current?.value,
90+
}),
91+
});
92+
const { result } = await response.json();
93+
setServerData(result ?? null);
94+
} catch (error) {
95+
console.error('Error fetching data:', error);
96+
}
97+
};
98+
799
return (
8100
<div className='w-[90%] mx-auto'>
9101
<div className='mx-auto text-4xl pt-10 text-center'>
10-
거래 내역 업로드 페이지
102+
거래 내역 테스트 짬통
103+
</div>
104+
<div className='flex flex-col pt-10'>
105+
<p className='pb-2'>업로드 테스트</p>
106+
<Badge variant='secondary' className='hover:bg-white'>
107+
<form
108+
id='uploadForm'
109+
className='flex justify-between items-center w-full'
110+
onSubmit={handleFileUpload}
111+
>
112+
<input type='file' id='fileInput' onChange={handleFileChange} />
113+
<Button type='submit'>업로드</Button>
114+
</form>
115+
</Badge>
116+
{fileContent && (
117+
<div>
118+
<span>파일 내용</span>
119+
<article className='text-wrap bg-white text-black p-[10px] rounded-[5px] overflow-auto max-h-[300px] border border-gray-300'>
120+
<p className='break-words whitespace-pre-wrap'>{fileContent}</p>
121+
</article>
122+
</div>
123+
)}
124+
</div>
125+
126+
<p className='pt-10'>아래 링크를 브라우저 북마크 바로 드래그하세요:</p>
127+
<div
128+
dangerouslySetInnerHTML={{
129+
__html: `<a href="${bookmarkletCode.trim()}" style="font-size: 18px; color: blue; text-decoration: underline; cursor: pointer;">[쿠키 복사 북마클릿]</a>`,
130+
}}
131+
/>
132+
133+
<p className='pt-10'>링크 오픈 테스트</p>
134+
<a
135+
href='#'
136+
onClick={() => openWindow('new-m.pay.naver.com/pcpay')}
137+
className='text-blue-600 text-[18px]'
138+
>
139+
[네이버페이 주문 목록 켜기]
140+
</a>
141+
142+
<p className='pt-10 pb-2'>네이버페이 주문내역 불러오기</p>
143+
<div className='flex w-full gap-3'>
144+
<input
145+
ref={cookieRef}
146+
type='text'
147+
className='border rounded-md text-black p-1 px-2 w-full'
148+
/>
149+
<Button className='bg-blue-500 hover:bg-blue-400' onClick={handleInput}>
150+
입력
151+
</Button>
152+
</div>
153+
<div>
154+
{serverData ? (
155+
<div className='pt-2'>
156+
<div className='flex'>
157+
<span className='w-1/3'>상품명</span>
158+
<span className='w-1/3'>구매일자</span>
159+
<span className='w-1/3'>가격</span>
160+
</div>
161+
<Separator />
162+
{serverData.items.map(({ name, date, price }, idx) => (
163+
<div className='flex' key={idx}>
164+
<span className='w-1/3'>{name}</span>
165+
<span className='w-1/3'>{date}</span>
166+
<span className='w-1/3'>{price}</span>
167+
</div>
168+
))}
169+
<Separator />
170+
<div className='flex gap-3 justify-end'>
171+
<span>총액</span>
172+
<span>{serverData.totalPrice}</span>
173+
</div>
174+
</div>
175+
) : (
176+
<>Loading...</>
177+
)}
11178
</div>
12-
<Badge variant='secondary' className=''>
13-
<form id='uploadForm'>
14-
<input type='file' id='fileInput' />
15-
<Button type='submit'>업로드</Button>
16-
</form>
17-
</Badge>
18179
</div>
19180
);
20181
}

‎app/api/coupang/route.ts

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import {
2+
coupangAddCart,
3+
// coupangClose,
4+
coupangGetPincode,
5+
coupangInsertPassword,
6+
coupangPayAll,
7+
coupangSetPincode,
8+
coupangSignIn,
9+
} from '@/actions/coupang/coupangActions';
10+
import { NextResponse } from 'next/server';
11+
12+
export type Item = {
13+
productId: string;
14+
itemId?: string;
15+
quantity: number;
16+
};
17+
18+
export type CoupangRequest = {
19+
pincode: string;
20+
password: string;
21+
itemList: Item[];
22+
};
23+
24+
export type CoupangResponse = {
25+
success: boolean;
26+
status: 'PINCODE' | 'PASSWORD' | 'COMPLETED';
27+
result: string;
28+
};
29+
30+
export async function POST(request: Request) {
31+
const { pincode, password, itemList } =
32+
(await request.json()) as CoupangRequest;
33+
34+
await coupangSignIn();
35+
36+
const isPincodePage = await coupangGetPincode();
37+
if (isPincodePage) {
38+
return NextResponse.json({
39+
success: true,
40+
status: 'PINCODE',
41+
result: 'PINCODE',
42+
});
43+
}
44+
45+
await coupangSetPincode(pincode);
46+
await coupangAddCart(itemList[0]);
47+
// await coupangAddCart(itemList[1]);
48+
49+
const screenshotBuffer = await coupangPayAll();
50+
if (screenshotBuffer) {
51+
const base64Image = `data:image/png;base64,${screenshotBuffer.toString('base64')}`;
52+
return NextResponse.json({
53+
success: true,
54+
status: 'PASSWORD',
55+
result: base64Image,
56+
});
57+
}
58+
59+
await coupangInsertPassword(password);
60+
61+
// await coupangClose();
62+
63+
return NextResponse.json({
64+
success: true,
65+
status: 'COMPLETED',
66+
result: '',
67+
});
68+
}

‎app/api/coupang/v2/route.ts

Whitespace-only changes.

‎app/api/naverpay/route.ts

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { NaverPayResponse } from '@/types/payment';
2+
import { NextResponse } from 'next/server';
3+
4+
type NaverPaymentItem = {
5+
name: string;
6+
date: string;
7+
price: number;
8+
};
9+
10+
export type NaverPaymentList = {
11+
items: NaverPaymentItem[];
12+
totalPrice: number;
13+
};
14+
15+
export async function POST(request: Request) {
16+
// Request
17+
const { url, cookie } = await request.json();
18+
19+
if (!url || !cookie) {
20+
return NextResponse.json(
21+
{ error: 'clientData is required' },
22+
{ status: 400 }
23+
);
24+
}
25+
console.log('url: ', url);
26+
console.log('cookie: ', cookie);
27+
28+
// GET Naverpay Payment List
29+
const response = await fetch(url, {
30+
method: 'GET',
31+
headers: {
32+
Cookie: cookie,
33+
},
34+
});
35+
const data = (await response.json()) as NaverPayResponse;
36+
const { items } = data.result;
37+
38+
let totalPrice = 0;
39+
const naverPaymentItems: NaverPaymentItem[] = [];
40+
items.forEach(
41+
({
42+
// serviceType,
43+
// status,
44+
// merchantName,
45+
product: {
46+
name,
47+
// imgUrl, infoUrl,
48+
price,
49+
},
50+
date,
51+
// productDetailUrl,
52+
// orderDetailUrl,
53+
}) => {
54+
naverPaymentItems.push({
55+
name,
56+
date: new Date(date).toLocaleDateString(),
57+
price,
58+
});
59+
totalPrice += price;
60+
}
61+
);
62+
const naverPaymentList: NaverPaymentList = {
63+
items: naverPaymentItems,
64+
totalPrice,
65+
};
66+
return NextResponse.json({ success: true, result: naverPaymentList });
67+
}

‎app/coupang/page.tsx

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
'use client';
2+
3+
import { CoupangResponse, Item } from '@/app/api/coupang/route';
4+
import PasswordInputForm from '@/components/coupang/passwordInputForm';
5+
import PincodeInputForm from '@/components/coupang/pincodeInputForm';
6+
import { Button } from '@/components/ui/button';
7+
import { useState } from 'react';
8+
9+
export type OnClick = ({
10+
itemList,
11+
pincode,
12+
password,
13+
}: {
14+
itemList: Item[];
15+
pincode?: string;
16+
password?: string;
17+
}) => Promise<void>;
18+
19+
export default function Coupang() {
20+
const [coupangResponse, setCoupangResponse] =
21+
useState<CoupangResponse | null>(null);
22+
23+
const itemList: Item[] = [
24+
{
25+
productId: '7666070794',
26+
itemId: '90437044721',
27+
quantity: 1,
28+
},
29+
{
30+
productId: '7958974',
31+
itemId: '91118401786',
32+
quantity: 2,
33+
},
34+
{
35+
productId: '2042132',
36+
itemId: '86533230299',
37+
quantity: 2,
38+
},
39+
{
40+
productId: '7591951475',
41+
quantity: 1,
42+
},
43+
];
44+
45+
const handleOnClick = async ({
46+
itemList,
47+
pincode = '',
48+
password = '',
49+
}: {
50+
itemList: Item[];
51+
pincode?: string;
52+
password?: string;
53+
}) => {
54+
try {
55+
// 클라이언트 데이터를 서버 API로 POST 요청
56+
const response = await fetch('/api/coupang', {
57+
method: 'POST',
58+
headers: {
59+
'Content-Type': 'application/json',
60+
},
61+
body: JSON.stringify({
62+
pincode,
63+
password,
64+
itemList,
65+
}),
66+
});
67+
const res = (await response.json()) as CoupangResponse;
68+
setCoupangResponse(res ?? null);
69+
} catch (error) {
70+
console.error('Error fetching data:', error);
71+
}
72+
};
73+
74+
if (!coupangResponse) {
75+
return (
76+
<div className='flex justify-center pt-10'>
77+
<Button
78+
variant={'secondary'}
79+
onClick={() => handleOnClick({ itemList })}
80+
>
81+
쿠팡
82+
</Button>
83+
</div>
84+
);
85+
}
86+
87+
const { status, result } = coupangResponse;
88+
89+
return (
90+
<>
91+
{status === 'PINCODE' ? (
92+
<PincodeInputForm onClick={handleOnClick} itemList={itemList} />
93+
) : status === 'PASSWORD' ? (
94+
<PasswordInputForm
95+
base64Image={result}
96+
onClick={handleOnClick}
97+
itemList={itemList}
98+
/>
99+
) : (
100+
<>COMPLETED</>
101+
)}
102+
</>
103+
);
104+
}

‎app/coupang/test/Circle.png

388 Bytes
Loading

‎app/coupang/test/image.png

12.9 KB
Loading

‎app/coupang/test/page.tsx

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
'use client';
2+
3+
import { CoupangResponse, Item } from '@/app/api/coupang/route';
4+
import PasswordInputForm from '@/components/coupang/passwordInputForm';
5+
import PincodeInputForm from '@/components/coupang/pincodeInputForm';
6+
import { Button } from '@/components/ui/button';
7+
import { useState } from 'react';
8+
9+
export type OnClick = ({
10+
itemList,
11+
pincode,
12+
password,
13+
}: {
14+
itemList: Item[];
15+
pincode?: string;
16+
password?: string;
17+
}) => Promise<void>;
18+
19+
export default function CoupangTest() {
20+
const [coupangResponse, setCoupangResponse] =
21+
useState<CoupangResponse | null>(null);
22+
23+
const itemList = [
24+
// {
25+
// pid: '7666070794',
26+
// itemId: '90437044721',
27+
// quantity: 1,
28+
// },
29+
// {
30+
// pid: '7958974',
31+
// itemId: '91118401786',
32+
// quantity: 2,
33+
// },
34+
// {
35+
// pid: '2042132',
36+
// itemId: '86533230299',
37+
// quantity: 2,
38+
// },
39+
{
40+
pid: '7591951475',
41+
quantity: 1,
42+
},
43+
];
44+
45+
const handleOnClick = async ({
46+
itemList,
47+
pincode = '',
48+
password = '',
49+
}: {
50+
itemList: Item[];
51+
pincode?: string;
52+
password?: string;
53+
}) => {
54+
try {
55+
// 클라이언트 데이터를 서버 API로 POST 요청
56+
const response = await fetch('/api/coupang', {
57+
method: 'POST',
58+
headers: {
59+
'Content-Type': 'application/json',
60+
},
61+
body: JSON.stringify({
62+
pincode,
63+
password,
64+
itemList,
65+
}),
66+
});
67+
const res = (await response.json()) as CoupangResponse;
68+
setCoupangResponse(res ?? null);
69+
} catch (error) {
70+
console.error('Error fetching data:', error);
71+
}
72+
};
73+
74+
if (!coupangResponse) {
75+
return (
76+
<div className='flex justify-center pt-10'>
77+
<Button
78+
variant={'secondary'}
79+
onClick={() => handleOnClick({ itemList })}
80+
>
81+
쿠팡
82+
</Button>
83+
</div>
84+
);
85+
}
86+
87+
const { status, result } = coupangResponse;
88+
89+
return (
90+
<>
91+
{status === 'PINCODE' ? (
92+
<PincodeInputForm onClick={handleOnClick} itemList={itemList} />
93+
) : status === 'PASSWORD' ? (
94+
<PasswordInputForm
95+
base64Image={result}
96+
onClick={handleOnClick}
97+
itemList={itemList}
98+
/>
99+
) : (
100+
<>COMPLETED</>
101+
)}
102+
</>
103+
);
104+
}

‎app/globals.css

+8
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@
5151
font-size: 18px; /* 1024px 이상 */
5252
}
5353
}
54+
55+
.keypad {
56+
@apply w-full h-full bg-opacity-0 bg-black hover:bg-opacity-30;
57+
}
58+
59+
.not-keypad {
60+
@apply hover:bg-opacity-0 cursor-default;
61+
}
5462
}
5563

5664
@layer base {
+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
'use client';
2+
3+
import Circle from '@/app/coupang/test/Circle.png';
4+
import { Button } from '@/components/ui/button';
5+
import Image from 'next/image';
6+
import { useState } from 'react';
7+
import { CoupangInputFormProps } from './pincodeInputForm';
8+
9+
export default function PasswordInputForm({
10+
base64Image,
11+
onClick,
12+
itemList,
13+
}: {
14+
base64Image: string;
15+
} & CoupangInputFormProps) {
16+
const [input, setInput] = useState<number[]>([]);
17+
18+
console.log('🚀 ~ PasswordInputForm ~ input:', input);
19+
20+
const handleSubmit = () => {
21+
// URL에 입력값을 추가해 페이지 새로고침
22+
if (input.length === 6) {
23+
onClick({ itemList, password: input.join('') });
24+
}
25+
};
26+
27+
const handleClick = (numpad: number) => {
28+
if (numpad === -1) {
29+
setInput(input.slice(0, -1));
30+
return;
31+
}
32+
33+
if (input.length < 6) {
34+
setInput([...input, numpad]);
35+
}
36+
};
37+
38+
return (
39+
<div className='flex flex-col w-full'>
40+
<div className='relative w-[500px] h-[792px] mx-auto'>
41+
<Image
42+
src={base64Image}
43+
alt='Coupang'
44+
width={500}
45+
height={792}
46+
unoptimized
47+
className='absolute'
48+
/>
49+
50+
<div className='absolute top-0 w-full h-[290px] flex flex-col justify-end items-center'>
51+
<div className='flex w-full h-[30px] px-[110px] justify-start gap-5'>
52+
{input.map((_, idx) => (
53+
<Image key={idx} src={Circle} alt='Circle' width={30}></Image>
54+
))}
55+
</div>
56+
</div>
57+
58+
<div className='absolute bottom-0 w-full h-[310px] flex flex-col justify-center items-center pb-[30px] pt-[20px] px-[25px] '>
59+
<div className='flex w-full h-full '>
60+
<button className='keypad' onClick={() => handleClick(0)}></button>
61+
<button className='keypad' onClick={() => handleClick(1)}></button>
62+
<button className='keypad' onClick={() => handleClick(2)}></button>
63+
</div>
64+
<div className='flex w-full h-full'>
65+
<button className='keypad' onClick={() => handleClick(3)}></button>
66+
<button className='keypad' onClick={() => handleClick(4)}></button>
67+
<button className='keypad' onClick={() => handleClick(5)}></button>
68+
</div>
69+
<div className='flex w-full h-full'>
70+
<button className='keypad' onClick={() => handleClick(6)}></button>
71+
<button className='keypad' onClick={() => handleClick(7)}></button>
72+
<button className='keypad' onClick={() => handleClick(8)}></button>
73+
</div>
74+
<div className='flex w-full h-full'>
75+
<button className='keypad not-keypad'></button>
76+
<button className='keypad' onClick={() => handleClick(9)}></button>
77+
<button className='keypad' onClick={() => handleClick(-1)}></button>
78+
</div>
79+
</div>
80+
</div>
81+
82+
<div className='flex gap-3 justify-center pt-10 items-center'>
83+
<label htmlFor='input'>비밀번호</label>
84+
<div className='px-2 py-1 rounded-md text-black bg-white w-[100px] h-8 text-center'>
85+
{input.join('')}
86+
</div>
87+
<Button onClick={handleSubmit} variant='secondary'>
88+
입력
89+
</Button>
90+
</div>
91+
</div>
92+
);
93+
}
+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
'use client';
2+
3+
import { Item } from '@/app/api/coupang/route';
4+
import { OnClick } from '@/app/coupang/test/page';
5+
import { useState } from 'react';
6+
import { Button } from '../ui/button';
7+
8+
export type CoupangInputFormProps = {
9+
onClick: OnClick;
10+
itemList: Item[];
11+
};
12+
13+
export default function PincodeInputForm({
14+
onClick,
15+
itemList,
16+
}: CoupangInputFormProps) {
17+
const [input, setInput] = useState('');
18+
19+
const handleSubmit = (e: React.FormEvent) => {
20+
e.preventDefault();
21+
onClick({ itemList, pincode: input });
22+
};
23+
24+
return (
25+
<form
26+
onSubmit={handleSubmit}
27+
className='flex gap-3 justify-center pt-10 items-center'
28+
>
29+
<label htmlFor='input'>인증번호</label>
30+
<input
31+
type='number'
32+
id='input'
33+
value={input}
34+
onChange={(e) => setInput(e.target.value)}
35+
required
36+
placeholder='인증번호를 입력하세요'
37+
className='px-2 py-1 rounded-md text-black'
38+
/>
39+
<Button type='submit' variant='secondary'>
40+
입력
41+
</Button>
42+
</form>
43+
);
44+
}

‎hooks/sessionBrowserManager.ts

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { Browser, chromium, Page } from 'playwright';
2+
3+
export type BrowserStatus = 'NOT_SIGNIN' | 'PINCODE' | 'SIGNIN' | 'PAYMENT';
4+
5+
export type SessionBrowser = {
6+
browser: Browser;
7+
page: Page;
8+
status: BrowserStatus;
9+
};
10+
11+
const setPageHeader = async (page: Page) =>
12+
await page.setExtraHTTPHeaders({
13+
'sec-ch-ua-platform': '"Windows"',
14+
'accept-language': 'ko,en-US;q=0.9,en;q=0.8',
15+
'sec-ch-ua':
16+
'"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
17+
'sec-fetch-site': 'same-origin',
18+
'sec-ch-ua-mobile': '?0',
19+
// 'sec-fetch-mode': 'cors',
20+
// 'sec-fetch-dest': 'empty',
21+
'accept-encoding': 'gzip, deflate, br, zstd',
22+
'user-agent':
23+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
24+
// 'x-requested-with': '',
25+
});
26+
27+
class SessionBrowserManager {
28+
private static instance: SessionBrowserManager | null;
29+
public browser!: Browser;
30+
public page!: Page;
31+
public status: BrowserStatus = 'NOT_SIGNIN';
32+
33+
constructor({ browser, page, status }: SessionBrowser) {
34+
if (SessionBrowserManager.instance) {
35+
console.log('Not Created!');
36+
return SessionBrowserManager.instance;
37+
}
38+
console.log('Created!');
39+
this.browser = browser;
40+
this.page = page;
41+
this.status = status;
42+
SessionBrowserManager.instance = this;
43+
}
44+
45+
public static async getInstance() {
46+
if (!this.instance) {
47+
console.log('New Browser Created!');
48+
const browser = await chromium.launch({
49+
executablePath:
50+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
51+
headless: false,
52+
args: [
53+
'--window-position=0,0',
54+
'--disable-quic',
55+
'--no-sandbox',
56+
'--disable-setuid-sandbox',
57+
'--disable-blink-features=AutomationControlled',
58+
'--start-minimized',
59+
'--log-level=3',
60+
],
61+
});
62+
const context = await browser.newContext({
63+
javaScriptEnabled: true,
64+
viewport: { width: 1280, height: 720 },
65+
deviceScaleFactor: 1, // 기본 스케일 설정
66+
});
67+
68+
await context.addInitScript(() => {
69+
Object.defineProperty(navigator, 'plugins', {
70+
get: () => [1, 2, 3], // 임의의 플러그인 값 추가
71+
});
72+
Object.defineProperty(navigator, 'mimeTypes', {
73+
get: () => ['application/pdf'], // PDF MIME 타입 추가
74+
});
75+
// window.chrome 속성 정의
76+
Object.defineProperty(window, 'chrome', {
77+
get: () => ({ runtime: {} }),
78+
});
79+
});
80+
81+
const page = await context.newPage();
82+
await setPageHeader(page);
83+
84+
this.instance = new this({
85+
browser,
86+
page,
87+
status: 'NOT_SIGNIN',
88+
});
89+
}
90+
console.log('Get Instance Excuted!');
91+
return this.instance;
92+
}
93+
94+
public static async close(): Promise<void> {
95+
if (this.instance) {
96+
const { browser, page } = this.instance;
97+
await page.close();
98+
await browser.close();
99+
this.instance = null;
100+
}
101+
}
102+
}
103+
104+
export default SessionBrowserManager;

‎package.json

+8-2
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@
4040
"lucide-react": "^0.469.0",
4141
"next": "14.2.23",
4242
"next-themes": "^0.4.4",
43-
"pnpm": "^9.15.3",
43+
"playwright": "^1.49.1",
44+
"playwright-extra": "^4.3.6",
45+
"playwright-extra-plugin-stealth": "^0.0.1",
46+
"puppeteer": "^23.11.1",
47+
"puppeteer-extra": "^3.3.6",
48+
"puppeteer-extra-plugin-stealth": "^2.11.2",
4449
"react": "^18",
4550
"react-chartjs-2": "^5.3.0",
4651
"react-dom": "^18",
@@ -63,8 +68,9 @@
6368
"@storybook/nextjs": "^8.4.7",
6469
"@storybook/react": "^8.4.7",
6570
"@storybook/test": "^8.4.7",
71+
"@playwright/test": "^1.49.1",
6672
"@trivago/prettier-plugin-sort-imports": "^5.2.1",
67-
"@types/node": "^20",
73+
"@types/node": "^20.17.12",
6874
"@types/react": "^18",
6975
"@types/react-dom": "^18",
7076
"eslint": "^8",

‎playwright.config.ts

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { defineConfig, devices } from '@playwright/test';
2+
3+
/**
4+
* Read environment variables from file.
5+
* https://github.com/motdotla/dotenv
6+
*/
7+
// import dotenv from 'dotenv';
8+
// import path from 'path';
9+
// dotenv.config({ path: path.resolve(__dirname, '.env') });
10+
11+
/**
12+
* See https://playwright.dev/docs/test-configuration.
13+
*/
14+
export default defineConfig({
15+
testDir: './tests',
16+
/* Run tests in files in parallel */
17+
fullyParallel: true,
18+
/* Fail the build on CI if you accidentally left test.only in the source code. */
19+
forbidOnly: !!process.env.CI,
20+
/* Retry on CI only */
21+
retries: process.env.CI ? 2 : 0,
22+
/* Opt out of parallel tests on CI. */
23+
workers: process.env.CI ? 1 : undefined,
24+
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
25+
reporter: 'html',
26+
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
27+
use: {
28+
/* Base URL to use in actions like `await page.goto('/')`. */
29+
// baseURL: 'http://127.0.0.1:3000',
30+
31+
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
32+
trace: 'on-first-retry',
33+
},
34+
35+
/* Configure projects for major browsers */
36+
projects: [
37+
{
38+
name: 'chromium',
39+
use: { ...devices['Desktop Chrome'] },
40+
},
41+
42+
{
43+
name: 'firefox',
44+
use: { ...devices['Desktop Firefox'] },
45+
},
46+
47+
{
48+
name: 'webkit',
49+
use: { ...devices['Desktop Safari'] },
50+
},
51+
52+
/* Test against mobile viewports. */
53+
// {
54+
// name: 'Mobile Chrome',
55+
// use: { ...devices['Pixel 5'] },
56+
// },
57+
// {
58+
// name: 'Mobile Safari',
59+
// use: { ...devices['iPhone 12'] },
60+
// },
61+
62+
/* Test against branded browsers. */
63+
// {
64+
// name: 'Microsoft Edge',
65+
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
66+
// },
67+
// {
68+
// name: 'Google Chrome',
69+
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
70+
// },
71+
],
72+
73+
/* Run your local dev server before starting the tests */
74+
// webServer: {
75+
// command: 'npm run start',
76+
// url: 'http://127.0.0.1:3000',
77+
// reuseExistingServer: !process.env.CI,
78+
// },
79+
});

‎pnpm-lock.yaml

+982-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎tests-examples/demo-todo-app.spec.ts

+437
Large diffs are not rendered by default.

‎tests/example.spec.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test('has title', async ({ page }) => {
4+
await page.goto('https://playwright.dev/');
5+
6+
// Expect a title "to contain" a substring.
7+
await expect(page).toHaveTitle(/Playwright/);
8+
});
9+
10+
test('get started link', async ({ page }) => {
11+
await page.goto('https://playwright.dev/');
12+
13+
// Click the get started link.
14+
await page.getByRole('link', { name: 'Get started' }).click();
15+
16+
// Expects page to have a heading with the name of Installation.
17+
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
18+
});

‎types/payment.ts

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
export type NaverPayProduct = {
2+
name: string;
3+
imgUrl: string;
4+
infoUrl: string;
5+
price: number;
6+
restAmount: number;
7+
isGift: false;
8+
};
9+
10+
type NaverPayStatus = {
11+
name: string;
12+
text: string;
13+
color: string;
14+
};
15+
16+
export type NaverPayServiceType = 'ORDER' | 'CONTENTS';
17+
18+
type NaverPayItem = {
19+
id: string;
20+
serviceType: NaverPayServiceType;
21+
status: NaverPayStatus;
22+
merchantName: string;
23+
product: NaverPayProduct;
24+
date: number;
25+
productDetailUrl: string;
26+
orderDetailUrl: string | null;
27+
};
28+
29+
type NaverPayResult = {
30+
success: boolean;
31+
items: NaverPayItem[];
32+
itemCount: number;
33+
totalPage: number;
34+
curPage: number;
35+
};
36+
37+
export type NaverPayResponse = {
38+
code: number;
39+
message: string;
40+
result: NaverPayResult;
41+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
declare module 'playwright-extra-plugin-stealth' {
2+
const stealth: () => any; // 정확한 타입을 알고 있다면 `any`를 구체화할 수 있습니다.
3+
export default stealth;
4+
}

0 commit comments

Comments
 (0)
Please sign in to comment.