@sayren/mcp 0.5.2 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +33 -3
- package/package.json +3 -3
- package/template/.template-meta.json +3 -2
- package/template/README.md +87 -55
- package/template/package.json +2 -2
- package/template/public/favicon.svg +1 -0
- package/template/src/components/product-card.tsx +2 -0
- package/template/src/components/site-header.tsx +16 -6
- package/template/src/components/submit-button.tsx +4 -0
- package/template/src/lib/config.server.ts +1 -1
- package/template/src/lib/guest-order.test.ts +18 -0
- package/template/src/lib/guest-order.ts +13 -0
- package/template/src/lib/order-status.ts +17 -0
- package/template/src/lib/page-title.test.ts +19 -0
- package/template/src/lib/page-title.ts +23 -0
- package/template/src/lib/payment-options.test.ts +61 -0
- package/template/src/lib/payment-options.ts +39 -0
- package/template/src/routeTree.gen.ts +42 -0
- package/template/src/routes/__root.tsx +40 -6
- package/template/src/routes/account.tsx +166 -4
- package/template/src/routes/cart.tsx +9 -2
- package/template/src/routes/checkout.complete.tsx +22 -10
- package/template/src/routes/checkout.index.tsx +40 -17
- package/template/src/routes/checkout.return.tsx +132 -20
- package/template/src/routes/guest-order.tsx +183 -0
- package/template/src/routes/index.tsx +2 -3
- package/template/src/routes/login.tsx +23 -3
- package/template/src/routes/orders.$orderId.tsx +13 -8
- package/template/src/routes/orders.index.tsx +2 -1
- package/template/src/routes/products.$productId.tsx +12 -5
- package/template/src/routes/products.index.tsx +6 -1
- package/template/src/routes/signup.tsx +235 -0
package/dist/index.mjs
CHANGED
|
@@ -718,6 +718,15 @@ const RULES = [
|
|
|
718
718
|
doc: "/guides/shipping-fees",
|
|
719
719
|
templateFile: "src/routes/checkout.index.tsx"
|
|
720
720
|
},
|
|
721
|
+
{
|
|
722
|
+
id: "payment-retry",
|
|
723
|
+
title: "결제가 실패·취소되면 같은 결제를 다른 결제 옵션으로 다시 시도하게 한다(`payments.retry`)",
|
|
724
|
+
why: "카드 거절·결제창 닫기 뒤에 주문서를 처음부터 다시 쓰게 하면 구매자가 이탈한다. 같은 결제에서 다른 결제사·결제수단으로 바꾸는 길이 있어야 한다",
|
|
725
|
+
fix: "복귀 화면의 실패·취소 결과(`CANCELED`·`FAILED`)에서 결제 옵션 목록을 보여 주고, 누르면 클릭 핸들러에서 `payments.retry(result.paymentId, { option })`를 부른다. 옵션은 주문서가 결제창을 열기 전에 결제 id별로 남겨 둔다",
|
|
726
|
+
example: "if (result.status === \"FAILED\" || result.status === \"CANCELED\") {\n // 버튼 클릭 핸들러에서 바로 부른다 — 팝업은 클릭 시점에 열어야 한다\n await payments.retry(result.paymentId, { option });\n}",
|
|
727
|
+
doc: "/guides/checkout-flow",
|
|
728
|
+
templateFile: "src/routes/checkout.return.tsx"
|
|
729
|
+
},
|
|
721
730
|
{
|
|
722
731
|
id: "checkout-origin",
|
|
723
732
|
title: "복귀 주소는 코드에 고정하지 않고 배포된 주소(요청 origin)에서 만든다",
|
|
@@ -763,6 +772,24 @@ const RULES = [
|
|
|
763
772
|
doc: "/guides/storefront-auth",
|
|
764
773
|
templateFile: "src/routes/account.tsx"
|
|
765
774
|
},
|
|
775
|
+
{
|
|
776
|
+
id: "buyer-signup",
|
|
777
|
+
title: "이메일·비밀번호 로그인을 받으면 가입 화면도 둔다(`auth.signUp`)",
|
|
778
|
+
why: "비밀번호 로그인만 있고 가입이 없으면 소셜 로그인을 켜지 않은 스토어에서 새 구매자가 계정을 만들 수 없다",
|
|
779
|
+
fix: "스토어 `loginMethods`에 `password`가 있으면 가입 화면을 둔다. 약관·개인정보 수집이용 동의(필수)와 마케팅 수신(선택)을 받아 서버 함수에서 `auth.signUp({ email, password, name, agreements, cartToken })`을 부르고, 받은 토큰 쌍을 HttpOnly 쿠키에 담는다",
|
|
780
|
+
example: "const tokens = await authFor().signUp({\n email, password, name,\n agreements: { terms: true, privacy: true, marketing },\n cartToken: readCartToken() ?? undefined, // 비회원 장바구니를 합친다\n});\nsignIn(tokens);",
|
|
781
|
+
doc: "/guides/storefront-auth",
|
|
782
|
+
templateFile: "src/routes/signup.tsx"
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
id: "buyer-signout",
|
|
786
|
+
title: "로그아웃은 서버에서 리프레시 토큰을 폐기하고 쿠키를 지운다(`auth.signOut`)",
|
|
787
|
+
why: "쿠키만 지우면 새 나간 리프레시 토큰이 14일 동안 계속 살아 있다. 로그아웃 화면이 없으면 공용 기기에서 계정을 닫을 방법이 없다",
|
|
788
|
+
fix: "계정 화면에 로그아웃을 두고, 서버 함수에서 `auth.signOut(accessToken)`을 부른 뒤 세션 쿠키를 지운다. 탈퇴(`auth.withdraw`)도 같은 화면에 둔다",
|
|
789
|
+
example: "const logout = createServerFn({ method: \"POST\" }).handler(async () => {\n const accessToken = readToken();\n if (accessToken) await authFor().signOut(accessToken).catch(() => undefined);\n clearSessionCookie();\n});",
|
|
790
|
+
doc: "/guides/storefront-auth",
|
|
791
|
+
templateFile: "src/routes/account.tsx"
|
|
792
|
+
},
|
|
766
793
|
{
|
|
767
794
|
id: "analytics-start",
|
|
768
795
|
title: "방문 분석은 브라우저에서 한 번 시작한다(`@sayren/storefront-sdk/analytics`)",
|
|
@@ -854,10 +881,13 @@ function verifySources(files) {
|
|
|
854
881
|
const fixedReturnUrl = matches(/returnUrl\s*[:=]\s*["'`]https?:\/\//);
|
|
855
882
|
if (fixedReturnUrl) inFile("checkout-origin", fixedReturnUrl, "복귀 주소를 고정 문자열로 두었다. 배포된 주소(요청 origin)에서 만든다", /returnUrl\s*[:=]\s*["'`]https?:\/\//);
|
|
856
883
|
if (has("startPayment(") && !has(".result(")) inProject("payment-return-route", "결제 복귀 화면에서 `payments.result()`를 부르는 코드가 없다");
|
|
884
|
+
if (has(".result(") && !matches(/\bpayments\.retry\(|\.retry\(\s*[\w.]*paymentId\b/)) inProject("payment-retry", "결제 복귀 화면에서 실패·취소 뒤 `payments.retry()`로 다른 결제 옵션을 다시 시도하는 코드가 없다");
|
|
857
885
|
if (has(".result(") && !has("PROCESSING")) inProject("payment-processing-status", "결과 확인 중(`PROCESSING`)일 때 결제 상태를 다시 조회하는 코드가 없다");
|
|
858
886
|
if (has("startPayment(") && has("zipCode") && !has("quoteDelivery(")) inProject("delivery-quote-preview", "배송지 우편번호를 받지만 `checkout.quoteDelivery`로 배송비를 다시 계산하는 코드가 없다");
|
|
859
887
|
if (has("createStorefrontAuth(")) {
|
|
860
888
|
if (!has(".refresh(")) inProject("buyer-session-refresh", "로그인은 하지만 액세스 토큰을 갱신하는 코드가 없다(`auth.refresh(refreshToken)`)");
|
|
889
|
+
if (has(".session(") && !has(".signUp(")) inProject("buyer-signup", "이메일·비밀번호 로그인(`auth.session`)은 있지만 가입(`auth.signUp`)을 부르는 코드가 없다");
|
|
890
|
+
if (!matches(/\b(auth|authFor\(\))\.signOut\(|\.signOut\(\s*accessToken\b/)) inProject("buyer-signout", "로그아웃에서 리프레시 토큰을 폐기하는 코드가 없다(`auth.signOut(accessToken)`)");
|
|
861
891
|
if (!has("reconsentRequired")) inProject("buyer-reconsent", "재동의가 필요한 구매자를 처리하는 코드가 없다(`reconsentRequired`)");
|
|
862
892
|
}
|
|
863
893
|
if (has("createStorefrontClient(") && !has("@sayren/storefront-sdk/analytics")) inProject("analytics-start", "방문 분석을 시작하는 코드가 없다. `@sayren/storefront-sdk/analytics`의 createAnalytics를 브라우저에서 한 번 부른다");
|
|
@@ -1024,7 +1054,7 @@ async function runCreate(argv) {
|
|
|
1024
1054
|
const at = finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
|
1025
1055
|
console.log(` · [${finding.ruleId}] ${at} — ${finding.fix}`);
|
|
1026
1056
|
}
|
|
1027
|
-
console.log(`\n다음:\n cd ${args.dir}\n
|
|
1057
|
+
console.log(`\n다음:\n cd ${args.dir}\n npm install\n npm run dev`);
|
|
1028
1058
|
console.log(`
|
|
1029
1059
|
디자인을 고친 뒤에는 MCP 도구 \`verify_storefront\`로 다시 검사해주십시오. 결제·구매자 인증·방문 분석 규칙 ${report$1.summary.rules}개를 보고, 어긴 항목마다 고칠 자리와 수정 방법·예시 코드를 알려 줍니다.\n결제 도메인(셀러 콘솔 설정 › 결제)을 비워 두면 https 복귀 주소를 모두 받습니다. 도메인을 등록했다면 이 쇼핑몰을 배포한 주소도 목록에 넣어주십시오(테스트 결제는 localhost가 항상 허용됩니다).`);
|
|
1030
1060
|
if (config$1) await new Telemetry(config$1).reportVerify("create", report$1, durationMs);
|
|
@@ -1066,7 +1096,7 @@ server.registerTool("get_scaffold_plan", {
|
|
|
1066
1096
|
"1. `list_template_files`로 파일 목록을 받는다.",
|
|
1067
1097
|
"2. 각 파일을 `get_template_file`로 받아 **그대로** 프로젝트에 쓴다. 코드를 새로 짓지 않는다.",
|
|
1068
1098
|
"3. `.env`에 `SAYREN_API_URL`과 `SAYREN_STORE_CODE`를 넣는다(아래 값).",
|
|
1069
|
-
"4. `
|
|
1099
|
+
"4. `npm install` 후 `npm run dev`로 띄워 http://localhost:4010 홈에 상품이 보이는지 확인한다(pnpm·yarn도 된다).",
|
|
1070
1100
|
"5. 사용자가 원하는 디자인은 확장 지점에서만 바꾼다.",
|
|
1071
1101
|
"6. 끝나면 `verify_storefront`로 검사한다. 위반마다 고칠 자리(`file`·`line`)와 수정 방법(`fix`)·예시(`example`)가 실려 오니 그대로 고치고 `ok: true`가 될 때까지 다시 부른다."
|
|
1072
1102
|
],
|
|
@@ -1096,7 +1126,7 @@ server.registerTool("get_scaffold_plan", {
|
|
|
1096
1126
|
}
|
|
1097
1127
|
],
|
|
1098
1128
|
doNotTouch: [
|
|
1099
|
-
"src/lib/payments.ts·src/routes/checkout.return.tsx — 결제창
|
|
1129
|
+
"src/lib/payments.ts·src/lib/payment-options.ts·src/routes/checkout.return.tsx — 결제창 열기, 결제 복귀 결과 확인, 다른 결제수단 재시도(브라우저)",
|
|
1100
1130
|
"src/lib/api.server.ts — 테넌트 헤더·토큰 전달·방문 식별 쿠키",
|
|
1101
1131
|
"src/lib/session.server.ts·src/start.ts — 구매자 세션 쿠키와 요청마다 한 번 하는 토큰 갱신",
|
|
1102
1132
|
"src/lib/analytics.ts — 방문 분석 시작(브라우저 한 번)과 시작 전 이벤트 큐",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sayren/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"sayren-mcp": "./dist/index.mjs"
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@modelcontextprotocol/sdk": "^1.22.0",
|
|
14
14
|
"zod": "^4.6.5",
|
|
15
|
-
"@sayren/
|
|
16
|
-
"@sayren/
|
|
15
|
+
"@sayren/store-sdk": "^0.16.0",
|
|
16
|
+
"@sayren/storefront-sdk": "^0.13.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@biomejs/biome": "^2.5.14",
|
package/template/README.md
CHANGED
|
@@ -1,48 +1,71 @@
|
|
|
1
1
|
# 스토어프론트 템플릿 (TanStack Router · TanStack Start SSR)
|
|
2
2
|
|
|
3
|
-
sayren MCP가
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
sayren MCP가 내려 주는 스토어프론트 시작점입니다. 구매 흐름 전체가 이미 붙어 있습니다.
|
|
4
|
+
|
|
5
|
+
| 화면 | 경로 |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| 홈 · 상품 목록·검색 · 상품 상세 | `/`, `/products`, `/products/$productId` |
|
|
8
|
+
| 장바구니 · 주문서 | `/cart`, `/checkout` |
|
|
9
|
+
| 결제 복귀(다른 결제수단 재시도) · 주문 완료 | `/checkout/return`, `/checkout/complete` |
|
|
10
|
+
| 로그인 · 회원가입 · 소셜 로그인 복귀 | `/login`, `/signup`, `/auth/callback` |
|
|
11
|
+
| 내 정보(재동의·로그아웃·탈퇴) · 주문 내역 · 주문 상세(취소·반품 신청) | `/account`, `/orders`, `/orders/$orderId` |
|
|
12
|
+
| 비회원 주문 조회 | `/guest-order` |
|
|
6
13
|
|
|
7
14
|
## 시작하기
|
|
8
15
|
|
|
9
16
|
```bash
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
npm install
|
|
18
|
+
npm run dev
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
pnpm·yarn을 써도 됩니다(`pnpm install`·`pnpm dev`). 개발 서버는 `http://localhost:4010`에서 뜹니다.
|
|
22
|
+
|
|
23
|
+
설정은 프로젝트 루트의 `.env`에 둡니다. `SAYREN_TOKEN`을 주고 `npx @sayren/mcp create`로 받았다면 이미 채워져 있습니다.
|
|
24
|
+
셸에서 준 값이 `.env`보다 우선합니다.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
SAYREN_API_URL=https://api.sayren.app/storefront/v1
|
|
28
|
+
SAYREN_STORE_CODE=내_스토어_코드
|
|
14
29
|
```
|
|
15
30
|
|
|
16
31
|
| 환경변수 | 뜻 |
|
|
17
32
|
| --- | --- |
|
|
18
33
|
| `SAYREN_API_URL` | 스토어프론트 API 베이스 |
|
|
19
|
-
| `SAYREN_STORE_CODE` |
|
|
20
|
-
| `SAYREN_ANALYTICS_DEBUG` | `1`이면 localhost에서도 방문 분석을
|
|
34
|
+
| `SAYREN_STORE_CODE` | 스토어 코드. 셀러 콘솔 설정 › 스토어 정보에서 확인합니다. 서브도메인으로 서비스하면 비워 둡니다 |
|
|
35
|
+
| `SAYREN_ANALYTICS_DEBUG` | `1`이면 localhost에서도 방문 분석을 보냅니다. 기본은 보내지 않습니다 |
|
|
21
36
|
|
|
22
|
-
`
|
|
23
|
-
|
|
24
|
-
운영 배포는 호스팅에 맞는 TanStack Start 배포 설정(Node 서버, Cloudflare Workers, Vercel 등)을 더한다.
|
|
37
|
+
`npm run build`는 `dist/`에 클라이언트와 서버 번들을 만들고, `npm run start`는 그 결과를 띄워 확인합니다.
|
|
38
|
+
운영 배포는 호스팅에 맞는 TanStack Start 배포 설정(Node 서버, Cloudflare Workers, Vercel 등)을 더합니다.
|
|
25
39
|
|
|
26
40
|
## 구조
|
|
27
41
|
|
|
28
42
|
| 자리 | 하는 일 |
|
|
29
43
|
| --- | --- |
|
|
30
|
-
| `src/routes/` | 파일 기반 라우트. 파일 이름이 곧
|
|
31
|
-
| `src/routeTree.gen.ts` | 라우트 트리. `
|
|
44
|
+
| `src/routes/` | 파일 기반 라우트. 파일 이름이 곧 경로입니다(`products.$productId.tsx` → `/products/:productId`) |
|
|
45
|
+
| `src/routeTree.gen.ts` | 라우트 트리. `npm run dev`·`npm run build`가 다시 만듭니다. 손으로 고치지 않습니다 |
|
|
32
46
|
| `src/start.ts` | 전역 요청 미들웨어. 서버 함수 CSRF 검사와 구매자 세션 갱신 |
|
|
33
|
-
| `src/lib/*.server.ts` | 서버 전용 코드. `process.env`·쿠키·API 클라이언트는 여기에만
|
|
47
|
+
| `src/lib/*.server.ts` | 서버 전용 코드. `process.env`·쿠키·API 클라이언트는 여기에만 둡니다 |
|
|
48
|
+
| `public/` | 그대로 서빙하는 파일(파비콘 `favicon.svg`) |
|
|
49
|
+
|
|
50
|
+
화면 데이터는 라우트 `loader`가 서버 함수(`createServerFn`)를 불러 받습니다. 첫 요청은 서버에서 그리고, 이후 이동은
|
|
51
|
+
브라우저가 같은 서버 함수를 호출합니다. 담기·결제 요청·로그인 같은 쓰기도 서버 함수라서 토큰이 브라우저 JS에 나가지 않습니다.
|
|
34
52
|
|
|
35
|
-
|
|
36
|
-
|
|
53
|
+
헤더와 브라우저 탭 제목은 셀러 콘솔 설정 › 스토어 정보의 스토어 이름·로고(`GET /store`)를 씁니다. 탭 제목은
|
|
54
|
+
`{화면} | {스토어 이름}`(예: `회원가입 | 나의첫번째몰`)이고 홈은 스토어 이름만 씁니다. 형식은 `src/lib/page-title.ts` 한 곳에서 바꿉니다.
|
|
37
55
|
|
|
38
56
|
## 방문 분석
|
|
39
57
|
|
|
40
|
-
`src/lib/analytics.ts`가 브라우저에서 방문 분석을
|
|
41
|
-
화면에서 `useTrack`으로
|
|
42
|
-
서버가
|
|
43
|
-
|
|
58
|
+
`src/lib/analytics.ts`가 브라우저에서 방문 분석을 시작합니다. 페이지뷰·체류는 자동으로 세고, 상품 조회·목록 노출·검색은
|
|
59
|
+
화면에서 `useTrack`으로 남깁니다. 장바구니·결제·구매는 `src/lib/api.server.ts`가 방문자 쿠키를 API 요청 헤더에 실어
|
|
60
|
+
서버가 기록합니다.
|
|
61
|
+
|
|
62
|
+
**localhost에서는 보내지 않습니다.** 개발 중인 방문이 통계에 섞이지 않게 하려는 기본값입니다. 개발 중에 확인하려면
|
|
63
|
+
`SAYREN_ANALYTICS_DEBUG=1`을 줍니다. 배포한 주소에서는 설정 없이 보냅니다.
|
|
64
|
+
|
|
65
|
+
동의 기본값은 `granted`(바로 수집)입니다. 개인정보를 저장하지 않는 1st-party 쿠키라 국내 쇼핑몰 기준으로 정했습니다.
|
|
44
66
|
EU 등 분석 쿠키에 사전 동의가 필요한 지역의 구매자를 받는다면 `src/lib/analytics.ts`의 `consent`를 `"pending"`으로 바꾸고,
|
|
45
|
-
쿠키 동의 배너에서 `setConsent("granted" | "denied")`를
|
|
67
|
+
쿠키 동의 배너에서 `setConsent("granted" | "denied")`를 부릅니다. 동의 전에는 쿠키를 만들지 않고 이벤트를 모아 두었다가
|
|
68
|
+
동의하면 보냅니다.
|
|
46
69
|
|
|
47
70
|
## 확장 지점
|
|
48
71
|
|
|
@@ -50,46 +73,55 @@ EU 등 분석 쿠키에 사전 동의가 필요한 지역의 구매자를 받는
|
|
|
50
73
|
| --- | --- |
|
|
51
74
|
| 브랜드 색·서체 | `src/styles.css`의 `@theme` |
|
|
52
75
|
| 헤더·전역 내비 | `src/components/site-header.tsx` |
|
|
76
|
+
| 파비콘 | `public/favicon.svg` |
|
|
53
77
|
| 상품 카드 | `src/components/product-card.tsx` |
|
|
54
78
|
| 화면 추가 | `src/routes/`에 파일 추가 |
|
|
55
|
-
| 약관 동의 | `src/routes/account.tsx`(동의 상태·재동의)·`src/routes/login.tsx`(소셜 가입 동의) |
|
|
79
|
+
| 약관 동의 | `src/routes/signup.tsx`(가입 동의)·`src/routes/account.tsx`(동의 상태·재동의)·`src/routes/login.tsx`(소셜 가입 동의) |
|
|
80
|
+
|
|
81
|
+
이메일·비밀번호 가입은 가입 화면(`/signup`)에서 이용약관·개인정보 수집이용(필수)과 마케팅 수신(선택) 동의를 함께 받습니다.
|
|
82
|
+
서버가 가입과 같은 트랜잭션에서 동의 이력을 남깁니다. 가입 화면은 셀러가 이메일·비밀번호 로그인을 켰을 때만 열립니다.
|
|
56
83
|
|
|
57
|
-
소셜 로그인은 공급자 화면을 거치므로 구매자가 무엇에 동의했는지는 이 앱만
|
|
58
|
-
받지 않고(기존 회원의 로그인까지 막히지 않게) **가입된 뒤 계정 화면에서
|
|
59
|
-
`GET /me`의 `reconsentRequired`가 `true`이고, 계정 화면의 약관 항목에서 동의하면 이력에
|
|
60
|
-
가입
|
|
61
|
-
|
|
62
|
-
|
|
84
|
+
소셜 로그인은 공급자 화면을 거치므로 구매자가 무엇에 동의했는지는 이 앱만 압니다. 이 템플릿은 로그인 화면에서 동의를
|
|
85
|
+
받지 않고(기존 회원의 로그인까지 막히지 않게) **가입된 뒤 계정 화면에서 받습니다**. 필수 동의 이력이 없는 구매자는
|
|
86
|
+
`GET /me`의 `reconsentRequired`가 `true`이고, 계정 화면의 약관 항목에서 동의하면 이력에 남습니다.
|
|
87
|
+
소셜 가입 전에 미리 받으려면 `login.tsx`의 `auth.idp(provider, { redirectUri, agreements })`에 그 값을 넘깁니다
|
|
88
|
+
(필수 동의는 `terms`·`privacy`를 함께 보냅니다). 서버가 로그인 흐름에 보관해 두고 계정을 만드는 것과 같은 트랜잭션에서
|
|
89
|
+
이력을 남깁니다. 어느 쪽이든 동의하지 않은 구매자가 동의한 것으로 남지는 않습니다.
|
|
63
90
|
|
|
64
91
|
## 건드리지 않는 편이 좋은 것
|
|
65
92
|
|
|
66
|
-
- `src/lib/payments.ts`·`src/routes/checkout.return.tsx` — 결제
|
|
67
|
-
(구매자 토큰이 브라우저 JS에 나가지
|
|
93
|
+
- `src/lib/payments.ts`·`src/lib/payment-options.ts`·`src/routes/checkout.return.tsx` — 결제 흐름입니다. 결제 시작은 서버 함수로
|
|
94
|
+
하고(구매자 토큰이 브라우저 JS에 나가지 않습니다), 결제창은 결제 서비스가 그립니다. 주문서는 버튼 클릭 시점에
|
|
68
95
|
`payments.prepareWindow()`로 빈 팝업을 먼저 열고(팝업 차단 회피) `payments.open(start, { window })`으로 결제창을
|
|
69
|
-
|
|
70
|
-
`payments.result()`로 결과를
|
|
71
|
-
다시 결제하게 하면 이중 결제가
|
|
72
|
-
- `src/lib/api.server.ts` — 테넌트 헤더와 토큰 전달
|
|
73
|
-
다른 사용자의 요청에
|
|
74
|
-
- `src/lib/session.server.ts`·`src/start.ts` — 구매자 세션 쿠키와
|
|
75
|
-
리프레시 토큰의 동시 갱신은 한 번으로
|
|
76
|
-
|
|
77
|
-
-
|
|
78
|
-
|
|
79
|
-
|
|
96
|
+
띄웁니다. 모바일·팝업 차단은 같은 탭이 결제 서비스로 이동하고 복귀 화면으로 돌아옵니다. 복귀 화면은
|
|
97
|
+
`payments.result()`로 결과를 읽습니다. 결과를 모르면(`PROCESSING`) 실패로 단정하지 않고 결제 상태를 계속 조회합니다.
|
|
98
|
+
다시 결제하게 하면 이중 결제가 납니다. 실패·취소면 주문서가 남겨 둔 결제 옵션으로 `payments.retry()`를 보여 줍니다.
|
|
99
|
+
- `src/lib/api.server.ts` — 테넌트 헤더와 토큰 전달 방식입니다. 토큰을 모듈 전역에 담으면 서버 렌더에서
|
|
100
|
+
다른 사용자의 요청에 섞입니다.
|
|
101
|
+
- `src/lib/session.server.ts`·`src/start.ts` — 구매자 세션 쿠키와 갱신입니다. 갱신은 요청마다 한 번이고, 같은
|
|
102
|
+
리프레시 토큰의 동시 갱신은 한 번으로 모읍니다. 리프레시 토큰은 한 번 쓰면 폐기되므로 각자 갱신하면 로그아웃됩니다.
|
|
103
|
+
로그아웃은 서버에서 `auth.signOut()`으로 리프레시 토큰을 폐기한 뒤 쿠키를 지웁니다.
|
|
104
|
+
- loader가 서버 함수로 데이터를 받아 그리는 구조 — 컴포넌트에서 다시 받으면 검색 노출과 첫 화면이 죽습니다.
|
|
105
|
+
- 결제 도메인 — 셀러 콘솔 설정 › 결제의 **결제 도메인**은 선택형 허용 목록입니다. 비워 두면 https 복귀 주소를 모두 받고,
|
|
106
|
+
등록하면 그 도메인에서만 결제가 됩니다. 등록했다면 이 쇼핑몰을 배포한 주소도 넣습니다. 배포 주소는 PG(토스페이먼츠·포트원)
|
|
107
|
+
가맹 정보에도 등록합니다. 테스트 결제는 localhost에서도 됩니다.
|
|
80
108
|
|
|
81
109
|
## 주의
|
|
82
110
|
|
|
83
|
-
- 주문서(`/checkout`)는 들어올 때마다 주문서 세션을 새로
|
|
84
|
-
매번 다시 계산해야 하기
|
|
85
|
-
- 비회원 장바구니는 서버가 발급한 토큰으로만
|
|
86
|
-
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
(
|
|
94
|
-
|
|
95
|
-
|
|
111
|
+
- 주문서(`/checkout`)는 들어올 때마다 주문서 세션을 새로 만듭니다. 조회 API가 없고, 가격과 재고를
|
|
112
|
+
매번 다시 계산해야 하기 때문입니다.
|
|
113
|
+
- 비회원 장바구니는 서버가 발급한 토큰으로만 찾습니다. 쿠키를 지우면 담은 상품이 사라집니다.
|
|
114
|
+
- 비회원 주문은 주문번호와 주문서에 적은 연락처·주문 조회 비밀번호로 찾습니다(`/guest-order`). 주문 완료 화면이 주문번호를
|
|
115
|
+
채워 보냅니다. 주문번호마다 10분에 5번까지 확인하고 5번째가 틀리면 30분 동안(하루 15번을 넘으면 그날 끝까지) `429 TOO_MANY_REQUESTS`라서,
|
|
116
|
+
화면은 남은 시간(`details.retryAfterSeconds`)을 분 단위로 안내합니다(`src/lib/guest-order.ts`). 주문이 없는 것과
|
|
117
|
+
연락처·비밀번호가 틀린 것은 같은 `404`입니다.
|
|
118
|
+
- 배송비는 배송 묶음마다 한 번 붙습니다. 같은 출고지 + 같은 배송 정책인 상품이 한 묶음이고, 제주·도서산간 추가
|
|
119
|
+
배송비는 묶음마다 더하며 무료배송이어도 부과합니다. 주문서를 만들 때는 배송지를 몰라 주문서의 `delivery.zipCode`가
|
|
120
|
+
null이고 추가 배송비가 0입니다. 주문서(`/checkout`)는 우편번호 입력을 마칠 때(blur) `quoteDelivery` 서버 함수로
|
|
121
|
+
`checkout.quoteDelivery(checkoutId, { zipCode })`를 불러 금액 요약을 갱신합니다(주문서를 바꾸지 않는 읽기 계산입니다).
|
|
122
|
+
**최종 금액은 결제 시작 응답의 `amounts`·`delivery`입니다.** 서버가 결제 시작이 보낸 배송지로 배송비를 다시 계산하므로
|
|
123
|
+
미리보기와 다를 수 있고, 화면은 그 값으로 덮어씁니다.
|
|
124
|
+
- 취소·반품은 수량 단위입니다. 주문 상세(`/orders/$orderId`)의 신청 수량 상한은 주문 상품의 `activeQuantity`
|
|
125
|
+
(남은 수량)이고, 접수 결과의 `expectedRefundAmount`는 수량 비율 상품 금액에서 반품 배송비를 뺀 값입니다.
|
|
126
|
+
배송비 환불은 `deliveryFeeRefundAmount`로 따로 옵니다. 교환은 희망 옵션(`exchangeOptionId`)이 필요해 이 화면에
|
|
127
|
+
넣지 않았습니다.
|
package/template/package.json
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"dev": "vite dev --port 4010",
|
|
7
|
+
"dev": "vite dev --port 4010 --strictPort",
|
|
8
8
|
"build": "vite build",
|
|
9
|
-
"start": "vite preview --port 4010",
|
|
9
|
+
"start": "vite preview --port 4010 --strictPort",
|
|
10
10
|
"lint": "biome check --write . && tsc --noEmit",
|
|
11
11
|
"typecheck": "tsc --noEmit",
|
|
12
12
|
"test": "vitest run"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><title>스토어</title><rect width="32" height="32" rx="7" fill="#202429"/><path d="M9 12h14l-1.4 11.2a2 2 0 0 1-2 1.8h-7.2a2 2 0 0 1-2-1.8z" fill="#fff"/><path d="M12.5 12V10.5a3.5 3.5 0 0 1 7 0V12" fill="none" stroke="#fff" stroke-width="1.8"/></svg>
|
|
@@ -11,6 +11,8 @@ export function ProductCard({ product }: { product: ProductCardData }) {
|
|
|
11
11
|
<Link
|
|
12
12
|
to="/products/$productId"
|
|
13
13
|
params={{ productId: product.productId }}
|
|
14
|
+
// 자동 테스트가 상품 카드를 찾는 표식이다. 카드 모양을 바꿔도 남겨 둔다
|
|
15
|
+
data-testid="product-card"
|
|
14
16
|
className="group flex flex-col gap-2"
|
|
15
17
|
>
|
|
16
18
|
<div className="relative aspect-square overflow-hidden rounded-lg bg-chip">
|
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import { Link } from "@tanstack/react-router";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
export
|
|
3
|
+
/** 헤더에 쓰는 스토어 정보 — `GET /store`의 이름·로고 */
|
|
4
|
+
export interface StoreBrand {
|
|
5
|
+
name: string;
|
|
6
|
+
logoUrl: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 확장 지점 — 로고·전역 내비게이션은 여기서 바꾼다.
|
|
11
|
+
* 스토어 이름·로고는 셀러가 콘솔 설정 › 스토어 정보에서 정한 값이다. 로고가 있으면 로고를, 없으면 이름을 보인다.
|
|
12
|
+
*/
|
|
13
|
+
export function SiteHeader({ store }: { store: StoreBrand | null }) {
|
|
14
|
+
const name = store?.name ?? "스토어";
|
|
5
15
|
return (
|
|
6
16
|
<header className="border-line border-b">
|
|
7
|
-
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-4 py-4">
|
|
8
|
-
<Link to="/" className="font-bold text-lg">
|
|
9
|
-
|
|
17
|
+
<div className="mx-auto flex w-full max-w-5xl flex-wrap items-center justify-between gap-3 px-4 py-4">
|
|
18
|
+
<Link to="/" className="flex items-center gap-2 font-bold text-lg">
|
|
19
|
+
{store?.logoUrl ? <img src={store.logoUrl} alt={name} className="h-8 w-auto" /> : name}
|
|
10
20
|
</Link>
|
|
11
|
-
<nav className="flex items-center gap-4 text-sm">
|
|
21
|
+
<nav aria-label="주요 메뉴" className="flex flex-wrap items-center gap-4 text-sm">
|
|
12
22
|
<Link to="/products" className="hover:text-point">
|
|
13
23
|
전체 상품
|
|
14
24
|
</Link>
|
|
@@ -9,12 +9,15 @@ export function SubmitButton({
|
|
|
9
9
|
className,
|
|
10
10
|
name,
|
|
11
11
|
value,
|
|
12
|
+
ariaLabel,
|
|
12
13
|
children,
|
|
13
14
|
}: {
|
|
14
15
|
disabled?: boolean;
|
|
15
16
|
className: string;
|
|
16
17
|
name?: string;
|
|
17
18
|
value?: string;
|
|
19
|
+
/** 버튼 글자만으로 무엇을 하는지 알 수 없을 때(목록의 [삭제] 등) 붙이는 접근성 이름 */
|
|
20
|
+
ariaLabel?: string;
|
|
18
21
|
children: React.ReactNode;
|
|
19
22
|
}) {
|
|
20
23
|
const hydrated = useHydrated();
|
|
@@ -23,6 +26,7 @@ export function SubmitButton({
|
|
|
23
26
|
type="submit"
|
|
24
27
|
name={name}
|
|
25
28
|
value={value}
|
|
29
|
+
aria-label={ariaLabel}
|
|
26
30
|
disabled={!hydrated || disabled}
|
|
27
31
|
className={className}
|
|
28
32
|
>
|
|
@@ -18,6 +18,6 @@ export function resolveStoreCode(): string {
|
|
|
18
18
|
const fromHost = storeCodeFromHost(getRequestHeader("host"));
|
|
19
19
|
if (fromHost) return fromHost;
|
|
20
20
|
throw new Error(
|
|
21
|
-
"스토어 코드를 알 수 없습니다. SAYREN_STORE_CODE 환경변수를 설정하거나 서브도메인으로
|
|
21
|
+
"스토어 코드를 알 수 없습니다. SAYREN_STORE_CODE 환경변수를 설정하거나 서브도메인으로 서비스해 주십시오",
|
|
22
22
|
);
|
|
23
23
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { guestLookupLockedMessage } from "./guest-order";
|
|
3
|
+
|
|
4
|
+
describe("guestLookupLockedMessage", () => {
|
|
5
|
+
it("남은 시간을 분 단위로 올려 알린다", () => {
|
|
6
|
+
expect(guestLookupLockedMessage(1800)).toBe(
|
|
7
|
+
"조회 시도가 너무 많습니다. 30분 뒤에 다시 시도해 주십시오",
|
|
8
|
+
);
|
|
9
|
+
expect(guestLookupLockedMessage(61)).toContain("2분 뒤");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("남은 시간을 모르면 잠시 후로 안내한다", () => {
|
|
13
|
+
expect(guestLookupLockedMessage(undefined)).toBe(
|
|
14
|
+
"조회 시도가 너무 많습니다. 잠시 후 다시 시도해 주십시오",
|
|
15
|
+
);
|
|
16
|
+
expect(guestLookupLockedMessage("30")).toContain("잠시 후");
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 비회원 주문 조회 잠금 안내 — API는 주문번호마다 시도 한도를 두고, 넘으면 429 `TOO_MANY_REQUESTS`와
|
|
3
|
+
* 남은 시간(`details.retryAfterSeconds`)을 준다. 남은 시간은 분 단위로 올려 알린다.
|
|
4
|
+
*/
|
|
5
|
+
export function guestLookupLockedMessage(retryAfterSeconds: unknown): string {
|
|
6
|
+
const minutes =
|
|
7
|
+
typeof retryAfterSeconds === "number" && retryAfterSeconds > 0
|
|
8
|
+
? Math.ceil(retryAfterSeconds / 60)
|
|
9
|
+
: null;
|
|
10
|
+
return minutes
|
|
11
|
+
? `조회 시도가 너무 많습니다. ${minutes}분 뒤에 다시 시도해 주십시오`
|
|
12
|
+
: "조회 시도가 너무 많습니다. 잠시 후 다시 시도해 주십시오";
|
|
13
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OrderItemStatus } from "@sayren/storefront-sdk";
|
|
2
|
+
|
|
3
|
+
/** 주문 상품 상태 라벨 — 화면에 코드 대신 보인다. 서버가 상태를 더하면 코드 그대로 보인다 */
|
|
4
|
+
const ORDER_ITEM_STATUS_LABELS: Record<OrderItemStatus, string> = {
|
|
5
|
+
PAYMENT_WAITING: "결제 대기",
|
|
6
|
+
PAID: "결제 완료",
|
|
7
|
+
CONFIRMED: "발송 준비",
|
|
8
|
+
DISPATCHED: "발송 완료",
|
|
9
|
+
DELIVERING: "배송 중",
|
|
10
|
+
DELIVERED: "배송 완료",
|
|
11
|
+
PURCHASE_DECIDED: "구매 확정",
|
|
12
|
+
CANCELED: "취소",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function orderItemStatusLabel(status: string): string {
|
|
16
|
+
return ORDER_ITEM_STATUS_LABELS[status as OrderItemStatus] ?? status;
|
|
17
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { pageTitle } from "./page-title";
|
|
3
|
+
|
|
4
|
+
const root = { loaderData: { store: { name: "나의첫번째몰", logoUrl: null } } };
|
|
5
|
+
|
|
6
|
+
describe("pageTitle", () => {
|
|
7
|
+
it("화면 이름 뒤에 스토어 이름을 붙인다", () => {
|
|
8
|
+
expect(pageTitle([root, { loaderData: {} }], "회원가입")).toBe("회원가입 | 나의첫번째몰");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("화면 이름이 없으면 스토어 이름만 쓴다", () => {
|
|
12
|
+
expect(pageTitle([root])).toBe("나의첫번째몰");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("스토어 이름을 읽지 못하면 기본 이름을 쓴다", () => {
|
|
16
|
+
expect(pageTitle([{ loaderData: { store: null } }], "장바구니")).toBe("장바구니 | 스토어");
|
|
17
|
+
expect(pageTitle([], "장바구니")).toBe("장바구니 | 스토어");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** 스토어 이름을 읽지 못했을 때 탭 제목 */
|
|
2
|
+
export const FALLBACK_STORE_NAME = "스토어";
|
|
3
|
+
|
|
4
|
+
/** 루트 라우트 loader가 내려 주는 스토어 이름 — `__root.tsx`의 `RootData.store` */
|
|
5
|
+
function storeNameOf(matches: ReadonlyArray<{ loaderData?: unknown }>): string | null {
|
|
6
|
+
const root = matches[0]?.loaderData as { store?: { name?: unknown } | null } | undefined;
|
|
7
|
+
const name = root?.store?.name;
|
|
8
|
+
return typeof name === "string" && name.trim() ? name : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 탭 제목 — `{화면} | {스토어 이름}`. 홈처럼 화면 이름이 없으면 스토어 이름만 쓴다.
|
|
13
|
+
* 라우트 `head`의 `matches`를 그대로 넘긴다(루트 loader가 스토어 이름을 싣는다).
|
|
14
|
+
*
|
|
15
|
+
* 확장 지점 — 구분자나 순서를 바꾸려면 여기만 고친다.
|
|
16
|
+
*/
|
|
17
|
+
export function pageTitle(
|
|
18
|
+
matches: ReadonlyArray<{ loaderData?: unknown }>,
|
|
19
|
+
screen?: string | null,
|
|
20
|
+
): string {
|
|
21
|
+
const store = storeNameOf(matches) ?? FALLBACK_STORE_NAME;
|
|
22
|
+
return screen ? `${screen} | ${store}` : store;
|
|
23
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { AvailablePaymentOption } from "@sayren/storefront-sdk";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
forgetPaymentOptions,
|
|
5
|
+
recallPaymentOptions,
|
|
6
|
+
rememberPaymentOptions,
|
|
7
|
+
} from "./payment-options";
|
|
8
|
+
|
|
9
|
+
const OPTIONS = [
|
|
10
|
+
{ pg: "tosspayments", method: "CARD", label: "신용·체크카드", pgName: "토스페이먼츠" },
|
|
11
|
+
{
|
|
12
|
+
pg: "portone",
|
|
13
|
+
method: "EASY_PAY",
|
|
14
|
+
provider: "KAKAOPAY",
|
|
15
|
+
label: "카카오페이",
|
|
16
|
+
pgName: "포트원",
|
|
17
|
+
},
|
|
18
|
+
] as AvailablePaymentOption[];
|
|
19
|
+
|
|
20
|
+
function memoryStorage(): Storage {
|
|
21
|
+
const data = new Map<string, string>();
|
|
22
|
+
return {
|
|
23
|
+
get length() {
|
|
24
|
+
return data.size;
|
|
25
|
+
},
|
|
26
|
+
clear: () => data.clear(),
|
|
27
|
+
getItem: (key) => data.get(key) ?? null,
|
|
28
|
+
key: (index) => [...data.keys()][index] ?? null,
|
|
29
|
+
removeItem: (key) => void data.delete(key),
|
|
30
|
+
setItem: (key, value) => void data.set(key, value),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("결제 재시도 옵션 보관", () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.stubGlobal("window", { sessionStorage: memoryStorage() });
|
|
37
|
+
});
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.unstubAllGlobals();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("결제 id별로 남기고 되읽는다", () => {
|
|
43
|
+
rememberPaymentOptions("pay_1", OPTIONS);
|
|
44
|
+
expect(recallPaymentOptions("pay_1")).toEqual(OPTIONS);
|
|
45
|
+
expect(recallPaymentOptions("pay_2")).toEqual([]);
|
|
46
|
+
forgetPaymentOptions("pay_1");
|
|
47
|
+
expect(recallPaymentOptions("pay_1")).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("형식이 깨진 값이나 저장소 오류는 빈 목록이다", () => {
|
|
51
|
+
window.sessionStorage.setItem("sayren.paymentOptions.pay_x", "{not json");
|
|
52
|
+
expect(recallPaymentOptions("pay_x")).toEqual([]);
|
|
53
|
+
vi.stubGlobal("window", {
|
|
54
|
+
get sessionStorage(): Storage {
|
|
55
|
+
throw new Error("blocked");
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
expect(() => rememberPaymentOptions("pay_1", OPTIONS)).not.toThrow();
|
|
59
|
+
expect(recallPaymentOptions("pay_1")).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type AvailablePaymentOption, availablePaymentOptionSchema } from "@sayren/storefront-sdk";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 결제 복귀 화면에서 다른 결제수단으로 다시 결제(`payments.retry`)할 때 보여 줄 결제 옵션 — 브라우저 전용.
|
|
6
|
+
*
|
|
7
|
+
* 옵션은 주문서를 만들 때만 내려온다. 복귀 화면(리다이렉트 결제)은 새 문서라 주문서가 준 옵션을 모르므로,
|
|
8
|
+
* 주문서가 결제창을 열기 전에 결제 id별로 탭 저장소(sessionStorage)에 남겨 둔다. 개인정보·토큰은 없다.
|
|
9
|
+
* 모바일 결제 앱을 거쳐 다른 탭으로 돌아오면 남은 값이 없고, 그때는 주문서로 돌아가 새로 결제한다.
|
|
10
|
+
*/
|
|
11
|
+
const KEY_PREFIX = "sayren.paymentOptions.";
|
|
12
|
+
const storedOptions = z.array(availablePaymentOptionSchema);
|
|
13
|
+
|
|
14
|
+
export function rememberPaymentOptions(paymentId: string, options: AvailablePaymentOption[]) {
|
|
15
|
+
try {
|
|
16
|
+
window.sessionStorage.setItem(`${KEY_PREFIX}${paymentId}`, JSON.stringify(options));
|
|
17
|
+
} catch {
|
|
18
|
+
// 저장소를 못 쓰는 브라우저(사생활 보호 모드 등) — 복귀 화면이 주문서로 돌아가라고 안내한다
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function recallPaymentOptions(paymentId: string): AvailablePaymentOption[] {
|
|
23
|
+
try {
|
|
24
|
+
const raw = window.sessionStorage.getItem(`${KEY_PREFIX}${paymentId}`);
|
|
25
|
+
if (!raw) return [];
|
|
26
|
+
const parsed = storedOptions.safeParse(JSON.parse(raw));
|
|
27
|
+
return parsed.success ? parsed.data : [];
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function forgetPaymentOptions(paymentId: string) {
|
|
34
|
+
try {
|
|
35
|
+
window.sessionStorage.removeItem(`${KEY_PREFIX}${paymentId}`);
|
|
36
|
+
} catch {
|
|
37
|
+
// 지우지 못해도 결제에는 영향이 없다
|
|
38
|
+
}
|
|
39
|
+
}
|