@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
|
@@ -12,7 +12,7 @@ import type { PaymentResult } from "@sayren/storefront-sdk/payments";
|
|
|
12
12
|
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
|
13
13
|
import { createServerFn } from "@tanstack/react-start";
|
|
14
14
|
import { getRequestUrl } from "@tanstack/react-start/server";
|
|
15
|
-
import { useState } from "react";
|
|
15
|
+
import { useId, useState } from "react";
|
|
16
16
|
import { z } from "zod";
|
|
17
17
|
import { SubmitButton } from "../components/submit-button";
|
|
18
18
|
import { apiFor } from "../lib/api.server";
|
|
@@ -21,6 +21,8 @@ import { checkoutIntentFrom, checkoutSearch } from "../lib/checkout-intent";
|
|
|
21
21
|
import type { PublicConfig } from "../lib/config";
|
|
22
22
|
import { API_BASE_URL, resolveStoreCode } from "../lib/config.server";
|
|
23
23
|
import { formatPrice } from "../lib/format";
|
|
24
|
+
import { pageTitle } from "../lib/page-title";
|
|
25
|
+
import { forgetPaymentOptions, rememberPaymentOptions } from "../lib/payment-options";
|
|
24
26
|
import { PAYMENT_RETURN_PATH, paymentsFor } from "../lib/payments";
|
|
25
27
|
import { readToken } from "../lib/session.server";
|
|
26
28
|
|
|
@@ -66,7 +68,7 @@ const quoteDelivery = createServerFn({ method: "POST" })
|
|
|
66
68
|
} catch {
|
|
67
69
|
return {
|
|
68
70
|
quote: null,
|
|
69
|
-
error: "배송비를 확인하지 못했습니다. 우편번호를 다시
|
|
71
|
+
error: "배송비를 확인하지 못했습니다. 우편번호를 다시 확인해 주십시오.",
|
|
70
72
|
};
|
|
71
73
|
}
|
|
72
74
|
});
|
|
@@ -129,20 +131,20 @@ const startPayment = createServerFn({ method: "POST" })
|
|
|
129
131
|
|
|
130
132
|
function startErrorMessage(error: unknown): string {
|
|
131
133
|
if (!(error instanceof ApiError))
|
|
132
|
-
return "결제를 시작하지 못했습니다. 잠시 후 다시
|
|
134
|
+
return "결제를 시작하지 못했습니다. 잠시 후 다시 시도해 주십시오.";
|
|
133
135
|
switch (error.code) {
|
|
134
136
|
case "PAYMENT_OPTION_UNAVAILABLE":
|
|
135
|
-
return "지금 쓸 수 없는 결제수단입니다. 다른 결제수단을
|
|
137
|
+
return "지금 쓸 수 없는 결제수단입니다. 다른 결제수단을 골라 주십시오.";
|
|
136
138
|
case "PAYMENT_PROVIDER_UNAVAILABLE":
|
|
137
|
-
return "결제창을 준비하지 못했습니다. 다른 결제수단으로
|
|
139
|
+
return "결제창을 준비하지 못했습니다. 다른 결제수단으로 시도해 주십시오.";
|
|
138
140
|
case "PAYMENT_NOT_CONFIGURED":
|
|
139
|
-
return "결제 수단을 준비하고 있습니다. 잠시 후 다시
|
|
141
|
+
return "결제 수단을 준비하고 있습니다. 잠시 후 다시 시도해 주십시오.";
|
|
140
142
|
case "CASH_RECEIPT_NOT_AVAILABLE":
|
|
141
143
|
return "현금영수증은 계좌이체·가상계좌 결제에서만 신청할 수 있습니다.";
|
|
142
144
|
case "RETURN_URL_NOT_ALLOWED":
|
|
143
|
-
return "이 쇼핑몰 주소에서는 결제할 수 없습니다. 셀러 콘솔 설정 › 결제에서 결제 도메인을
|
|
145
|
+
return "이 쇼핑몰 주소에서는 결제할 수 없습니다. 셀러 콘솔 설정 › 결제에서 결제 도메인을 확인해 주십시오.";
|
|
144
146
|
default:
|
|
145
|
-
return "결제를 시작하지 못했습니다. 입력한 내용을 확인하고 다시
|
|
147
|
+
return "결제를 시작하지 못했습니다. 입력한 내용을 확인하고 다시 시도해 주십시오.";
|
|
146
148
|
}
|
|
147
149
|
}
|
|
148
150
|
|
|
@@ -150,7 +152,7 @@ export const Route = createFileRoute("/checkout/")({
|
|
|
150
152
|
validateSearch: checkoutSearch,
|
|
151
153
|
loaderDeps: ({ search }) => search,
|
|
152
154
|
loader: ({ deps, location }) => createCheckout({ data: { search: deps, back: location.href } }),
|
|
153
|
-
head: () => ({ meta: [{ title: "주문서" }] }),
|
|
155
|
+
head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "주문서") }] }),
|
|
154
156
|
component: Checkout,
|
|
155
157
|
});
|
|
156
158
|
|
|
@@ -196,6 +198,7 @@ function Checkout() {
|
|
|
196
198
|
|
|
197
199
|
const settle = (result: PaymentResult, back: string) => {
|
|
198
200
|
if (result.status === "COMPLETED") {
|
|
201
|
+
forgetPaymentOptions(result.paymentId);
|
|
199
202
|
void navigate({
|
|
200
203
|
to: "/checkout/complete",
|
|
201
204
|
search: { orderId: result.orderId },
|
|
@@ -214,10 +217,10 @@ function Checkout() {
|
|
|
214
217
|
}
|
|
215
218
|
setNotice(
|
|
216
219
|
result.status === "CANCELED"
|
|
217
|
-
? "결제를 취소했습니다. 다른 결제수단으로 다시
|
|
220
|
+
? "결제를 취소했습니다. 다른 결제수단으로 다시 시도해 주십시오."
|
|
218
221
|
: result.status === "FAILED"
|
|
219
222
|
? result.message
|
|
220
|
-
: "결제 결과를 확인하지 못했습니다. 주문 내역을
|
|
223
|
+
: "결제 결과를 확인하지 못했습니다. 주문 내역을 확인해 주십시오.",
|
|
221
224
|
);
|
|
222
225
|
};
|
|
223
226
|
|
|
@@ -225,7 +228,7 @@ function Checkout() {
|
|
|
225
228
|
event.preventDefault();
|
|
226
229
|
const option = options.find((item) => paymentOptionKey(item) === selected);
|
|
227
230
|
if (!option) {
|
|
228
|
-
setNotice("결제수단을
|
|
231
|
+
setNotice("결제수단을 골라 주십시오.");
|
|
229
232
|
return;
|
|
230
233
|
}
|
|
231
234
|
const form = new FormData(event.currentTarget);
|
|
@@ -237,7 +240,7 @@ function Checkout() {
|
|
|
237
240
|
identityNumber: field("cashReceiptNumber"),
|
|
238
241
|
});
|
|
239
242
|
if (!parsed.success) {
|
|
240
|
-
setNotice(parsed.error.issues[0]?.message ?? "현금영수증 번호를
|
|
243
|
+
setNotice(parsed.error.issues[0]?.message ?? "현금영수증 번호를 확인해 주십시오.");
|
|
241
244
|
return;
|
|
242
245
|
}
|
|
243
246
|
cashReceipt = parsed.data;
|
|
@@ -274,12 +277,14 @@ function Checkout() {
|
|
|
274
277
|
const paymentStart = paymentStartSchema.parse(JSON.parse(start));
|
|
275
278
|
// 확정 금액 — 서버가 보낸 배송지로 배송비를 다시 계산했다. 화면에 보여 준 금액과 다를 수 있다
|
|
276
279
|
setQuote({ amounts: paymentStart.amounts, delivery: paymentStart.delivery });
|
|
280
|
+
// 리다이렉트 결제가 실패해 복귀 화면으로 오면 같은 결제를 다른 옵션으로 다시 시도한다 — 그때 쓸 목록
|
|
281
|
+
rememberPaymentOptions(paymentStart.paymentId, options);
|
|
277
282
|
// 결제 서비스가 결제창을 그린다. 팝업이면 결과를 여기서 받고, 리다이렉트면 이 탭이 떠난다
|
|
278
283
|
settle(await payments.open(paymentStart, { window: paymentWindow }), back);
|
|
279
284
|
})
|
|
280
285
|
.catch(() => {
|
|
281
286
|
paymentWindow.close();
|
|
282
|
-
setNotice("결제를 시작하지 못했습니다. 잠시 후 다시
|
|
287
|
+
setNotice("결제를 시작하지 못했습니다. 잠시 후 다시 시도해 주십시오.");
|
|
283
288
|
})
|
|
284
289
|
.finally(() => setSubmitting(false));
|
|
285
290
|
};
|
|
@@ -445,7 +450,11 @@ function Checkout() {
|
|
|
445
450
|
테스트 결제입니다. 실제로 돈이 오가지 않습니다.
|
|
446
451
|
</p>
|
|
447
452
|
) : null}
|
|
448
|
-
{notice ?
|
|
453
|
+
{notice ? (
|
|
454
|
+
<p role="status" className="rounded-md bg-chip px-3 py-2 text-sm">
|
|
455
|
+
{notice}
|
|
456
|
+
</p>
|
|
457
|
+
) : null}
|
|
449
458
|
</aside>
|
|
450
459
|
</div>
|
|
451
460
|
);
|
|
@@ -481,11 +490,18 @@ function Field({
|
|
|
481
490
|
hint?: string;
|
|
482
491
|
onBlur?: (value: string) => void;
|
|
483
492
|
}) {
|
|
493
|
+
const hintId = useId();
|
|
484
494
|
return (
|
|
485
495
|
<label className="block space-y-1">
|
|
486
496
|
<span className="text-sm">
|
|
487
497
|
{label}
|
|
488
|
-
{required
|
|
498
|
+
{/* 필수 표시는 눈으로만 본다 — 입력의 required가 보조 기술에 필수임을 알린다 */}
|
|
499
|
+
{required ? (
|
|
500
|
+
<span aria-hidden="true" className="text-point">
|
|
501
|
+
{" "}
|
|
502
|
+
*
|
|
503
|
+
</span>
|
|
504
|
+
) : null}
|
|
489
505
|
</span>
|
|
490
506
|
{/* 프리필은 defaultValue로 준다 — SSR HTML에 값이 실린다 */}
|
|
491
507
|
<input
|
|
@@ -494,10 +510,17 @@ function Field({
|
|
|
494
510
|
defaultValue={defaultValue}
|
|
495
511
|
placeholder={placeholder}
|
|
496
512
|
required={required}
|
|
513
|
+
// 이름은 라벨 글자 그대로다(필수 표시·안내 문구는 빼고) — 안내는 aria-describedby로 잇는다
|
|
514
|
+
aria-label={label}
|
|
515
|
+
aria-describedby={hint ? hintId : undefined}
|
|
497
516
|
onBlur={onBlur ? (event) => onBlur(event.target.value) : undefined}
|
|
498
517
|
className="h-11 w-full rounded-md border border-line px-3"
|
|
499
518
|
/>
|
|
500
|
-
{hint ?
|
|
519
|
+
{hint ? (
|
|
520
|
+
<span id={hintId} className="block text-muted text-xs">
|
|
521
|
+
{hint}
|
|
522
|
+
</span>
|
|
523
|
+
) : null}
|
|
501
524
|
</label>
|
|
502
525
|
);
|
|
503
526
|
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { ApiError, type AvailablePaymentOption, paymentOptionKey } from "@sayren/storefront-sdk";
|
|
1
2
|
import type { PaymentResult } from "@sayren/storefront-sdk/payments";
|
|
2
3
|
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
|
3
4
|
import { createServerFn } from "@tanstack/react-start";
|
|
4
|
-
import { useEffect, useState } from "react";
|
|
5
|
+
import { useCallback, useEffect, useState } from "react";
|
|
5
6
|
import { z } from "zod";
|
|
6
7
|
import type { PublicConfig } from "../lib/config";
|
|
7
8
|
import { API_BASE_URL, resolveStoreCode } from "../lib/config.server";
|
|
9
|
+
import { pageTitle } from "../lib/page-title";
|
|
10
|
+
import { forgetPaymentOptions, recallPaymentOptions } from "../lib/payment-options";
|
|
8
11
|
import { paymentsFor } from "../lib/payments";
|
|
9
12
|
import { safeRedirect } from "../lib/safe-redirect";
|
|
10
13
|
|
|
@@ -34,18 +37,26 @@ const POLL_TIMES = 15;
|
|
|
34
37
|
type View =
|
|
35
38
|
| { kind: "working" }
|
|
36
39
|
| { kind: "processing" }
|
|
37
|
-
| { kind: "failed"; canceled: boolean; message: string }
|
|
40
|
+
| { kind: "failed"; paymentId: string; canceled: boolean; message: string }
|
|
38
41
|
| { kind: "unknown" };
|
|
39
42
|
|
|
40
43
|
/**
|
|
41
44
|
* 결제 복귀 화면 — 결제 서비스가 리다이렉트 결제를 마치고 돌려보내는 곳이다. 결과는 주소의 결제 id로
|
|
42
45
|
* 결제 상태를 조회해 정한다(`payments.result`). 같은 주소로 다시 열어도(뒤로 가기·새로고침) 안전하다.
|
|
46
|
+
*
|
|
47
|
+
* 실패·취소면 같은 결제를 다른 결제 옵션으로 다시 시도한다(`payments.retry`). 옵션은 주문서가 결제창을 열기 전에
|
|
48
|
+
* 남겨 둔 목록이다(`payment-options.ts`). 목록이 없으면 주문서로 돌아가 새로 결제한다.
|
|
43
49
|
*/
|
|
44
50
|
export const Route = createFileRoute("/checkout/return")({
|
|
45
51
|
validateSearch: returnSearch,
|
|
46
52
|
loader: () => getConfig(),
|
|
47
53
|
// 복귀 주소의 결제 id가 외부 리소스 요청의 Referer로 새지 않게 한다
|
|
48
|
-
head: () => ({
|
|
54
|
+
head: ({ matches }) => ({
|
|
55
|
+
meta: [
|
|
56
|
+
{ title: pageTitle(matches, "결제 확인") },
|
|
57
|
+
{ name: "referrer", content: "no-referrer" },
|
|
58
|
+
],
|
|
59
|
+
}),
|
|
49
60
|
component: PaymentReturn,
|
|
50
61
|
});
|
|
51
62
|
|
|
@@ -54,21 +65,34 @@ function PaymentReturn() {
|
|
|
54
65
|
const { back } = Route.useSearch();
|
|
55
66
|
const navigate = useNavigate();
|
|
56
67
|
const [view, setView] = useState<View>({ kind: "working" });
|
|
68
|
+
const [retryError, setRetryError] = useState<string | null>(null);
|
|
69
|
+
// 남겨 둔 옵션은 탭 저장소에 있다 — 서버 렌더에는 없으므로 실패가 확정된 뒤(브라우저)에 읽는다
|
|
70
|
+
const [options, setOptions] = useState<AvailablePaymentOption[]>([]);
|
|
71
|
+
/** 다시 시도한 결제의 결과가 확인 중이면 올려서 결제 상태 조회를 처음부터 다시 돌린다(같은 결제 id다) */
|
|
72
|
+
const [round, setRound] = useState(0);
|
|
57
73
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const done = (orderId: string) =>
|
|
74
|
+
const complete = useCallback(
|
|
75
|
+
(paymentId: string, orderId: string) => {
|
|
76
|
+
forgetPaymentOptions(paymentId);
|
|
62
77
|
void navigate({ to: "/checkout/complete", search: { orderId }, replace: true });
|
|
78
|
+
},
|
|
79
|
+
[navigate],
|
|
80
|
+
);
|
|
63
81
|
|
|
64
|
-
|
|
82
|
+
/** 확정된 결과면 화면을 정하고 true. `PROCESSING`이면 false — 실패로 단정하지 않는다 */
|
|
83
|
+
const show = useCallback(
|
|
84
|
+
(result: PaymentResult): boolean => {
|
|
65
85
|
if (result.status === "COMPLETED") {
|
|
66
|
-
|
|
86
|
+
complete(result.paymentId, result.orderId);
|
|
67
87
|
return true;
|
|
68
88
|
}
|
|
69
89
|
if (result.status === "CANCELED" || result.status === "FAILED") {
|
|
90
|
+
// 만료된 결제는 다시 시도할 수 없다(409 PAYMENT_EXPIRED) — 주문서로 돌아가 새로 결제한다
|
|
91
|
+
if (result.code === "EXPIRED") forgetPaymentOptions(result.paymentId);
|
|
92
|
+
setOptions(result.code === "EXPIRED" ? [] : recallPaymentOptions(result.paymentId));
|
|
70
93
|
setView({
|
|
71
94
|
kind: "failed",
|
|
95
|
+
paymentId: result.paymentId,
|
|
72
96
|
canceled: result.status === "CANCELED",
|
|
73
97
|
message: result.message,
|
|
74
98
|
});
|
|
@@ -79,12 +103,24 @@ function PaymentReturn() {
|
|
|
79
103
|
return true;
|
|
80
104
|
}
|
|
81
105
|
return false;
|
|
106
|
+
},
|
|
107
|
+
[complete],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
let active = true;
|
|
112
|
+
const payments = paymentsFor(config);
|
|
113
|
+
const settle = (result: PaymentResult): boolean => {
|
|
114
|
+
if (!active) return true;
|
|
115
|
+
return show(result);
|
|
82
116
|
};
|
|
83
117
|
|
|
84
118
|
const run = async () => {
|
|
119
|
+
// 다시 시도한 결제를 확인하는 중이면 처음부터 확인 중으로 보인다
|
|
120
|
+
if (round > 0) setView({ kind: "processing" });
|
|
85
121
|
const first = await payments.result(window.location.href).catch(() => null);
|
|
86
122
|
if (!active) return;
|
|
87
|
-
if (first &&
|
|
123
|
+
if (first && settle(first)) return;
|
|
88
124
|
// 결과 확인 중 — 실패로 단정하지 않고 결제 상태를 다시 조회한다
|
|
89
125
|
setView({ kind: "processing" });
|
|
90
126
|
for (let i = 0; i < POLL_TIMES && active; i += 1) {
|
|
@@ -92,7 +128,7 @@ function PaymentReturn() {
|
|
|
92
128
|
if (!active) return;
|
|
93
129
|
const next = await payments.result(window.location.href).catch(() => null);
|
|
94
130
|
if (!active) return;
|
|
95
|
-
if (next &&
|
|
131
|
+
if (next && settle(next)) return;
|
|
96
132
|
}
|
|
97
133
|
if (active) setView({ kind: "unknown" });
|
|
98
134
|
};
|
|
@@ -101,7 +137,35 @@ function PaymentReturn() {
|
|
|
101
137
|
return () => {
|
|
102
138
|
active = false;
|
|
103
139
|
};
|
|
104
|
-
}, [config,
|
|
140
|
+
}, [config, show, round]);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 같은 결제를 다른 옵션으로 다시 시도한다. 클릭 핸들러에서 바로 부른다 — 팝업은 클릭 시점에 열어야 차단되지 않는다.
|
|
144
|
+
* 복귀 주소는 결제 시작 때의 값을 그대로 쓴다. 모바일은 이 탭이 결제 서비스로 이동했다가 다시 이 화면으로 돌아온다.
|
|
145
|
+
*/
|
|
146
|
+
const retry = (paymentId: string, option: AvailablePaymentOption, previous: View) => {
|
|
147
|
+
setRetryError(null);
|
|
148
|
+
setView({ kind: "working" });
|
|
149
|
+
paymentsFor(config)
|
|
150
|
+
.retry(paymentId, { option })
|
|
151
|
+
.then((result) => {
|
|
152
|
+
if (result.status === "PROCESSING") setRound((value) => value + 1);
|
|
153
|
+
else show(result);
|
|
154
|
+
})
|
|
155
|
+
.catch((error: unknown) => {
|
|
156
|
+
// 결제창을 열지 못했다 — 방금 보던 실패 화면으로 되돌린다
|
|
157
|
+
setView(previous);
|
|
158
|
+
setRetryError(retryErrorMessage(error));
|
|
159
|
+
// 더 진행할 수 없는 결제면 옵션을 거두고 주문서로 돌아가게 한다
|
|
160
|
+
if (
|
|
161
|
+
error instanceof ApiError &&
|
|
162
|
+
(error.code === "PAYMENT_EXPIRED" || error.code === "ALREADY_PROCESSED")
|
|
163
|
+
) {
|
|
164
|
+
forgetPaymentOptions(paymentId);
|
|
165
|
+
setOptions([]);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
};
|
|
105
169
|
|
|
106
170
|
return (
|
|
107
171
|
<div className="mx-auto max-w-md space-y-6 py-16 text-center">
|
|
@@ -110,8 +174,8 @@ function PaymentReturn() {
|
|
|
110
174
|
<h1 className="font-bold text-2xl">결제를 확인하고 있습니다</h1>
|
|
111
175
|
<p className="text-muted text-sm">
|
|
112
176
|
{view.kind === "processing"
|
|
113
|
-
? "결제사에서 결과를 확인하는 중입니다. 이 화면을 닫지 말고 잠시만
|
|
114
|
-
: "잠시만
|
|
177
|
+
? "결제사에서 결과를 확인하는 중입니다. 이 화면을 닫지 말고 잠시만 기다려 주십시오."
|
|
178
|
+
: "잠시만 기다려 주십시오."}
|
|
115
179
|
</p>
|
|
116
180
|
</>
|
|
117
181
|
) : view.kind === "failed" ? (
|
|
@@ -120,18 +184,49 @@ function PaymentReturn() {
|
|
|
120
184
|
{view.canceled ? "결제를 취소했습니다" : "결제하지 못했습니다"}
|
|
121
185
|
</h1>
|
|
122
186
|
{view.canceled ? null : <p className="text-muted text-sm">{view.message}</p>}
|
|
123
|
-
|
|
124
|
-
<
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
187
|
+
{options.length ? (
|
|
188
|
+
<section aria-labelledby="retry-heading" className="space-y-2 text-left">
|
|
189
|
+
<h2 id="retry-heading" className="font-bold text-sm">
|
|
190
|
+
다른 결제수단으로 다시 결제
|
|
191
|
+
</h2>
|
|
192
|
+
<div className="grid gap-2">
|
|
193
|
+
{options.map((option) => (
|
|
194
|
+
<button
|
|
195
|
+
key={paymentOptionKey(option)}
|
|
196
|
+
type="button"
|
|
197
|
+
data-testid="retry-option"
|
|
198
|
+
onClick={() => retry(view.paymentId, option, view)}
|
|
199
|
+
className="flex items-center justify-between rounded-md border border-line px-4 py-3 text-sm hover:border-ink"
|
|
200
|
+
>
|
|
201
|
+
<span className="font-medium">{option.label}</span>
|
|
202
|
+
<span className="text-muted text-xs">{option.pgName}</span>
|
|
203
|
+
</button>
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
{retryError ? (
|
|
207
|
+
<p role="alert" className="text-point text-sm">
|
|
208
|
+
{retryError}
|
|
209
|
+
</p>
|
|
210
|
+
) : null}
|
|
211
|
+
</section>
|
|
212
|
+
) : null}
|
|
213
|
+
<a
|
|
214
|
+
href={back ?? "/cart"}
|
|
215
|
+
className={
|
|
216
|
+
options.length
|
|
217
|
+
? "inline-block text-sm underline"
|
|
218
|
+
: "inline-block rounded-md bg-ink px-5 py-3 text-white"
|
|
219
|
+
}
|
|
220
|
+
>
|
|
221
|
+
{options.length ? "주문서로 돌아가기" : "주문서로 돌아가 다시 결제"}
|
|
222
|
+
</a>
|
|
128
223
|
</>
|
|
129
224
|
) : (
|
|
130
225
|
<>
|
|
131
226
|
<h1 className="font-bold text-2xl">결제 결과를 확인하지 못했습니다</h1>
|
|
132
227
|
<p className="text-muted text-sm">
|
|
133
228
|
결제를 마쳤다면 잠시 뒤 주문 내역에 나타납니다. 다시 결제하기 전에 주문 내역을 먼저
|
|
134
|
-
|
|
229
|
+
확인해 주십시오.
|
|
135
230
|
</p>
|
|
136
231
|
<Link to="/orders" className="inline-block rounded-md border border-ink px-5 py-3">
|
|
137
232
|
주문 내역
|
|
@@ -141,3 +236,20 @@ function PaymentReturn() {
|
|
|
141
236
|
</div>
|
|
142
237
|
);
|
|
143
238
|
}
|
|
239
|
+
|
|
240
|
+
function retryErrorMessage(error: unknown): string {
|
|
241
|
+
if (error instanceof ApiError) {
|
|
242
|
+
switch (error.code) {
|
|
243
|
+
case "PAYMENT_OPTION_UNAVAILABLE":
|
|
244
|
+
return "지금 쓸 수 없는 결제수단입니다. 다른 결제수단을 골라 주십시오.";
|
|
245
|
+
case "PAYMENT_PROVIDER_UNAVAILABLE":
|
|
246
|
+
return "결제창을 준비하지 못했습니다. 다른 결제수단으로 시도해 주십시오.";
|
|
247
|
+
case "PAYMENT_IN_PROGRESS":
|
|
248
|
+
return "결제를 확인하고 있습니다. 잠시 뒤 주문 내역을 확인해 주십시오.";
|
|
249
|
+
case "PAYMENT_EXPIRED":
|
|
250
|
+
case "ALREADY_PROCESSED":
|
|
251
|
+
return "이 결제는 더 진행할 수 없습니다. 주문서로 돌아가 새로 결제해 주십시오.";
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return "다시 결제하지 못했습니다. 주문서로 돌아가 새로 결제해 주십시오.";
|
|
255
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { ApiError, type MyOrder } from "@sayren/storefront-sdk";
|
|
2
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
3
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { ProductThumb } from "../components/product-thumb";
|
|
7
|
+
import { SubmitButton } from "../components/submit-button";
|
|
8
|
+
import { apiFor } from "../lib/api.server";
|
|
9
|
+
import { formatDateTime, formatPrice } from "../lib/format";
|
|
10
|
+
import { guestLookupLockedMessage } from "../lib/guest-order";
|
|
11
|
+
import { orderItemStatusLabel } from "../lib/order-status";
|
|
12
|
+
import { pageTitle } from "../lib/page-title";
|
|
13
|
+
|
|
14
|
+
const guestOrderSearch = z.object({
|
|
15
|
+
/** 주문 완료 화면이 주문번호를 채워 보낸다 */
|
|
16
|
+
orderId: z.string().optional().catch(undefined),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 비회원 주문 조회 — 주문번호와 주문할 때 적은 연락처·주문 조회 비밀번호로 찾는다.
|
|
21
|
+
* 비밀번호가 주소에 남지 않도록 서버 함수(POST)로 보낸다.
|
|
22
|
+
*/
|
|
23
|
+
const lookupGuestOrder = createServerFn({ method: "POST" })
|
|
24
|
+
.validator(z.object({ orderId: z.string(), phone: z.string(), orderPassword: z.string() }))
|
|
25
|
+
.handler(async ({ data }) => {
|
|
26
|
+
const orderId = data.orderId.trim();
|
|
27
|
+
const phone = data.phone.replace(/[^0-9]/g, "");
|
|
28
|
+
if (!orderId || !phone || !data.orderPassword) {
|
|
29
|
+
return { order: null, error: "주문번호·연락처·비밀번호를 모두 입력해 주십시오" };
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const order: MyOrder = await apiFor().myOrders.getGuestOrder(orderId, {
|
|
33
|
+
phone,
|
|
34
|
+
orderPassword: data.orderPassword,
|
|
35
|
+
});
|
|
36
|
+
return { order, error: null };
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error instanceof ApiError && (error.status === 401 || error.status === 404)) {
|
|
39
|
+
return { order: null, error: "주문을 찾지 못했습니다. 입력한 내용을 확인해 주십시오" };
|
|
40
|
+
}
|
|
41
|
+
// 주문번호마다 시도 한도가 있다. 여러 번 틀리면 한동안 맞는 비밀번호로도 조회되지 않는다
|
|
42
|
+
if (error instanceof ApiError && error.status === 429) {
|
|
43
|
+
return { order: null, error: guestLookupLockedMessage(error.details?.retryAfterSeconds) };
|
|
44
|
+
}
|
|
45
|
+
return { order: null, error: "조회하지 못했습니다. 잠시 후 다시 시도해 주십시오" };
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const Route = createFileRoute("/guest-order")({
|
|
50
|
+
validateSearch: guestOrderSearch,
|
|
51
|
+
head: ({ matches }) => ({
|
|
52
|
+
// 주소에 주문번호(`?orderId=`)가 실린다. 외부 링크로 나갈 때 리퍼러로 흘리지 않는다
|
|
53
|
+
meta: [
|
|
54
|
+
{ title: pageTitle(matches, "비회원 주문 조회") },
|
|
55
|
+
{ name: "referrer", content: "no-referrer" },
|
|
56
|
+
],
|
|
57
|
+
}),
|
|
58
|
+
component: GuestOrder,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function GuestOrder() {
|
|
62
|
+
const { orderId } = Route.useSearch();
|
|
63
|
+
const [order, setOrder] = useState<MyOrder | null>(null);
|
|
64
|
+
const [error, setError] = useState<string | null>(null);
|
|
65
|
+
const [pending, setPending] = useState(false);
|
|
66
|
+
|
|
67
|
+
const submit = (event: React.FormEvent<HTMLFormElement>) => {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
const form = new FormData(event.currentTarget);
|
|
70
|
+
setPending(true);
|
|
71
|
+
setError(null);
|
|
72
|
+
void lookupGuestOrder({
|
|
73
|
+
data: {
|
|
74
|
+
orderId: String(form.get("orderId") ?? ""),
|
|
75
|
+
phone: String(form.get("phone") ?? ""),
|
|
76
|
+
orderPassword: String(form.get("orderPassword") ?? ""),
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
.then((result) => {
|
|
80
|
+
setOrder(result.order);
|
|
81
|
+
setError(result.error);
|
|
82
|
+
})
|
|
83
|
+
.catch(() => {
|
|
84
|
+
setOrder(null);
|
|
85
|
+
setError("조회하지 못했습니다. 잠시 후 다시 시도해 주십시오");
|
|
86
|
+
})
|
|
87
|
+
.finally(() => setPending(false));
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div className="mx-auto max-w-lg space-y-6 py-10">
|
|
92
|
+
<div className="space-y-1">
|
|
93
|
+
<h1 className="font-bold text-2xl">비회원 주문 조회</h1>
|
|
94
|
+
<p className="text-muted text-sm">
|
|
95
|
+
주문할 때 입력한 연락처와 주문 조회 비밀번호를 넣어 주십시오.
|
|
96
|
+
</p>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<form onSubmit={submit} className="space-y-3">
|
|
100
|
+
<Field label="주문번호" name="orderId" defaultValue={orderId} placeholder="ord_" />
|
|
101
|
+
<Field label="연락처" name="phone" type="tel" placeholder="01012345678" />
|
|
102
|
+
<Field label="주문 조회 비밀번호" name="orderPassword" type="password" />
|
|
103
|
+
<SubmitButton
|
|
104
|
+
disabled={pending}
|
|
105
|
+
className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
|
|
106
|
+
>
|
|
107
|
+
조회하기
|
|
108
|
+
</SubmitButton>
|
|
109
|
+
</form>
|
|
110
|
+
{error ? (
|
|
111
|
+
<p role="alert" className="text-point text-sm">
|
|
112
|
+
{error}
|
|
113
|
+
</p>
|
|
114
|
+
) : null}
|
|
115
|
+
|
|
116
|
+
{order ? (
|
|
117
|
+
<section aria-label="주문 정보" className="space-y-3 rounded-lg border border-line p-5">
|
|
118
|
+
<div className="space-y-1">
|
|
119
|
+
<h2 className="font-bold text-lg">주문 {order.orderId}</h2>
|
|
120
|
+
<p className="text-muted text-sm">{formatDateTime(order.orderedAt)}</p>
|
|
121
|
+
</div>
|
|
122
|
+
<ul className="divide-y divide-line">
|
|
123
|
+
{order.items.map((item) => (
|
|
124
|
+
<li key={item.orderItemId} className="flex items-center gap-3 py-3 text-sm">
|
|
125
|
+
<ProductThumb
|
|
126
|
+
src={item.thumbnailUrl}
|
|
127
|
+
alt={item.productName}
|
|
128
|
+
className="size-14 rounded-md bg-chip object-cover"
|
|
129
|
+
/>
|
|
130
|
+
<span className="flex flex-1 flex-col gap-0.5">
|
|
131
|
+
<span>{item.productName}</span>
|
|
132
|
+
{item.optionName ? (
|
|
133
|
+
<span className="text-muted text-xs">{item.optionName}</span>
|
|
134
|
+
) : null}
|
|
135
|
+
<span className="text-muted text-xs">
|
|
136
|
+
{orderItemStatusLabel(item.status)} · 수량 {item.quantity}개
|
|
137
|
+
</span>
|
|
138
|
+
</span>
|
|
139
|
+
<span>{formatPrice(item.totalPrice)}</span>
|
|
140
|
+
</li>
|
|
141
|
+
))}
|
|
142
|
+
</ul>
|
|
143
|
+
<div className="flex justify-between border-line border-t pt-2 font-bold text-sm">
|
|
144
|
+
<span>결제 금액</span>
|
|
145
|
+
<span>{formatPrice(order.payment.totalAmount)}</span>
|
|
146
|
+
</div>
|
|
147
|
+
<p className="text-muted text-xs">
|
|
148
|
+
취소·반품은 스토어 고객센터로 문의해 주십시오. 회원으로 주문하면 주문 내역에서 직접
|
|
149
|
+
신청할 수 있습니다.
|
|
150
|
+
</p>
|
|
151
|
+
</section>
|
|
152
|
+
) : null}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function Field({
|
|
158
|
+
label,
|
|
159
|
+
name,
|
|
160
|
+
type = "text",
|
|
161
|
+
defaultValue,
|
|
162
|
+
placeholder,
|
|
163
|
+
}: {
|
|
164
|
+
label: string;
|
|
165
|
+
name: string;
|
|
166
|
+
type?: string;
|
|
167
|
+
defaultValue?: string;
|
|
168
|
+
placeholder?: string;
|
|
169
|
+
}) {
|
|
170
|
+
return (
|
|
171
|
+
<label className="block space-y-1">
|
|
172
|
+
<span className="text-sm">{label}</span>
|
|
173
|
+
<input
|
|
174
|
+
type={type}
|
|
175
|
+
name={name}
|
|
176
|
+
defaultValue={defaultValue}
|
|
177
|
+
placeholder={placeholder}
|
|
178
|
+
required
|
|
179
|
+
className="h-11 w-full rounded-md border border-line px-3"
|
|
180
|
+
/>
|
|
181
|
+
</label>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
@@ -20,9 +20,8 @@ const getHome = createServerFn({ method: "GET" }).handler(async () => {
|
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
export const Route = createFileRoute("/")({
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}),
|
|
23
|
+
// 제목은 루트가 스토어 이름으로 정한다
|
|
24
|
+
head: () => ({ meta: [{ name: "description", content: "지금 인기 있는 상품" }] }),
|
|
26
25
|
loader: () => getHome(),
|
|
27
26
|
component: Home,
|
|
28
27
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ApiError, type SocialProvider } from "@sayren/storefront-sdk";
|
|
2
|
-
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
3
3
|
import { createServerFn } from "@tanstack/react-start";
|
|
4
4
|
import { getRequestUrl } from "@tanstack/react-start/server";
|
|
5
5
|
import { useState } from "react";
|
|
@@ -7,6 +7,7 @@ import { z } from "zod";
|
|
|
7
7
|
import { SubmitButton } from "../components/submit-button";
|
|
8
8
|
import { apiFor, authFor } from "../lib/api.server";
|
|
9
9
|
import { clearCartToken, readCartToken } from "../lib/cart-session.server";
|
|
10
|
+
import { pageTitle } from "../lib/page-title";
|
|
10
11
|
import { safeRedirect } from "../lib/safe-redirect";
|
|
11
12
|
import { signIn } from "../lib/session.server";
|
|
12
13
|
import { SOCIAL_BUTTONS } from "../lib/social-buttons";
|
|
@@ -91,7 +92,7 @@ const CALLBACK_ERRORS: Record<string, string> = {
|
|
|
91
92
|
export const Route = createFileRoute("/login")({
|
|
92
93
|
validateSearch: loginSearch,
|
|
93
94
|
loader: () => getLoginMethods(),
|
|
94
|
-
head: () => ({ meta: [{ title: "로그인" }] }),
|
|
95
|
+
head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "로그인") }] }),
|
|
95
96
|
component: Login,
|
|
96
97
|
});
|
|
97
98
|
|
|
@@ -174,14 +175,18 @@ function Login() {
|
|
|
174
175
|
<input
|
|
175
176
|
type="email"
|
|
176
177
|
name="email"
|
|
178
|
+
aria-label="이메일"
|
|
177
179
|
placeholder="이메일"
|
|
180
|
+
autoComplete="email"
|
|
178
181
|
required
|
|
179
182
|
className="h-11 w-full rounded-md border border-line px-3"
|
|
180
183
|
/>
|
|
181
184
|
<input
|
|
182
185
|
type="password"
|
|
183
186
|
name="password"
|
|
187
|
+
aria-label="비밀번호"
|
|
184
188
|
placeholder="비밀번호"
|
|
189
|
+
autoComplete="current-password"
|
|
185
190
|
required
|
|
186
191
|
className="h-11 w-full rounded-md border border-line px-3"
|
|
187
192
|
/>
|
|
@@ -193,10 +198,25 @@ function Login() {
|
|
|
193
198
|
</SubmitButton>
|
|
194
199
|
</form>
|
|
195
200
|
) : null}
|
|
196
|
-
{error ?
|
|
201
|
+
{error ? (
|
|
202
|
+
<p role="alert" className="text-point text-sm">
|
|
203
|
+
{error}
|
|
204
|
+
</p>
|
|
205
|
+
) : null}
|
|
197
206
|
{social.length ? (
|
|
198
207
|
<p className="text-center text-muted text-xs">처음 소셜 로그인하면 가입이 함께 됩니다</p>
|
|
199
208
|
) : null}
|
|
209
|
+
<div className="flex justify-center gap-4 text-sm">
|
|
210
|
+
{/* 이메일·비밀번호 가입은 그 로그인 방법이 켜진 스토어에서만 된다 */}
|
|
211
|
+
{password ? (
|
|
212
|
+
<Link to="/signup" search={{ redirectTo }} className="underline">
|
|
213
|
+
회원가입
|
|
214
|
+
</Link>
|
|
215
|
+
) : null}
|
|
216
|
+
<Link to="/guest-order" className="text-muted underline">
|
|
217
|
+
비회원 주문 조회
|
|
218
|
+
</Link>
|
|
219
|
+
</div>
|
|
200
220
|
</div>
|
|
201
221
|
);
|
|
202
222
|
}
|