@sayren/mcp 0.5.1 → 0.6.0
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 +38 -8
- package/package.json +3 -3
- package/template/.template-meta.json +3 -2
- package/template/README.md +84 -54
- 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/order-status.ts +17 -0
- package/template/src/lib/payment-options.test.ts +61 -0
- package/template/src/lib/payment-options.ts +39 -0
- package/template/src/lib/payments.ts +2 -1
- package/template/src/routeTree.gen.ts +42 -0
- package/template/src/routes/__root.tsx +38 -6
- package/template/src/routes/account.tsx +164 -3
- package/template/src/routes/cart.tsx +7 -1
- package/template/src/routes/checkout.complete.tsx +20 -9
- package/template/src/routes/checkout.index.tsx +39 -17
- package/template/src/routes/checkout.return.tsx +125 -19
- package/template/src/routes/guest-order.tsx +171 -0
- package/template/src/routes/index.tsx +2 -3
- package/template/src/routes/login.tsx +21 -2
- package/template/src/routes/orders.$orderId.tsx +9 -7
- package/template/src/routes/products.$productId.tsx +8 -4
- package/template/src/routes/products.index.tsx +4 -0
- package/template/src/routes/signup.tsx +234 -0
|
@@ -1,10 +1,12 @@
|
|
|
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 { forgetPaymentOptions, recallPaymentOptions } from "../lib/payment-options";
|
|
8
10
|
import { paymentsFor } from "../lib/payments";
|
|
9
11
|
import { safeRedirect } from "../lib/safe-redirect";
|
|
10
12
|
|
|
@@ -34,12 +36,15 @@ const POLL_TIMES = 15;
|
|
|
34
36
|
type View =
|
|
35
37
|
| { kind: "working" }
|
|
36
38
|
| { kind: "processing" }
|
|
37
|
-
| { kind: "failed"; canceled: boolean; message: string }
|
|
39
|
+
| { kind: "failed"; paymentId: string; canceled: boolean; message: string }
|
|
38
40
|
| { kind: "unknown" };
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
43
|
* 결제 복귀 화면 — 결제 서비스가 리다이렉트 결제를 마치고 돌려보내는 곳이다. 결과는 주소의 결제 id로
|
|
42
44
|
* 결제 상태를 조회해 정한다(`payments.result`). 같은 주소로 다시 열어도(뒤로 가기·새로고침) 안전하다.
|
|
45
|
+
*
|
|
46
|
+
* 실패·취소면 같은 결제를 다른 결제 옵션으로 다시 시도한다(`payments.retry`). 옵션은 주문서가 결제창을 열기 전에
|
|
47
|
+
* 남겨 둔 목록이다(`payment-options.ts`). 목록이 없으면 주문서로 돌아가 새로 결제한다.
|
|
43
48
|
*/
|
|
44
49
|
export const Route = createFileRoute("/checkout/return")({
|
|
45
50
|
validateSearch: returnSearch,
|
|
@@ -54,21 +59,34 @@ function PaymentReturn() {
|
|
|
54
59
|
const { back } = Route.useSearch();
|
|
55
60
|
const navigate = useNavigate();
|
|
56
61
|
const [view, setView] = useState<View>({ kind: "working" });
|
|
62
|
+
const [retryError, setRetryError] = useState<string | null>(null);
|
|
63
|
+
// 남겨 둔 옵션은 탭 저장소에 있다 — 서버 렌더에는 없으므로 실패가 확정된 뒤(브라우저)에 읽는다
|
|
64
|
+
const [options, setOptions] = useState<AvailablePaymentOption[]>([]);
|
|
65
|
+
/** 다시 시도한 결제의 결과가 확인 중이면 올려서 결제 상태 조회를 처음부터 다시 돌린다(같은 결제 id다) */
|
|
66
|
+
const [round, setRound] = useState(0);
|
|
57
67
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const done = (orderId: string) =>
|
|
68
|
+
const complete = useCallback(
|
|
69
|
+
(paymentId: string, orderId: string) => {
|
|
70
|
+
forgetPaymentOptions(paymentId);
|
|
62
71
|
void navigate({ to: "/checkout/complete", search: { orderId }, replace: true });
|
|
72
|
+
},
|
|
73
|
+
[navigate],
|
|
74
|
+
);
|
|
63
75
|
|
|
64
|
-
|
|
76
|
+
/** 확정된 결과면 화면을 정하고 true. `PROCESSING`이면 false — 실패로 단정하지 않는다 */
|
|
77
|
+
const show = useCallback(
|
|
78
|
+
(result: PaymentResult): boolean => {
|
|
65
79
|
if (result.status === "COMPLETED") {
|
|
66
|
-
|
|
80
|
+
complete(result.paymentId, result.orderId);
|
|
67
81
|
return true;
|
|
68
82
|
}
|
|
69
83
|
if (result.status === "CANCELED" || result.status === "FAILED") {
|
|
84
|
+
// 만료된 결제는 다시 시도할 수 없다(409 PAYMENT_EXPIRED) — 주문서로 돌아가 새로 결제한다
|
|
85
|
+
if (result.code === "EXPIRED") forgetPaymentOptions(result.paymentId);
|
|
86
|
+
setOptions(result.code === "EXPIRED" ? [] : recallPaymentOptions(result.paymentId));
|
|
70
87
|
setView({
|
|
71
88
|
kind: "failed",
|
|
89
|
+
paymentId: result.paymentId,
|
|
72
90
|
canceled: result.status === "CANCELED",
|
|
73
91
|
message: result.message,
|
|
74
92
|
});
|
|
@@ -79,12 +97,24 @@ function PaymentReturn() {
|
|
|
79
97
|
return true;
|
|
80
98
|
}
|
|
81
99
|
return false;
|
|
100
|
+
},
|
|
101
|
+
[complete],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
let active = true;
|
|
106
|
+
const payments = paymentsFor(config);
|
|
107
|
+
const settle = (result: PaymentResult): boolean => {
|
|
108
|
+
if (!active) return true;
|
|
109
|
+
return show(result);
|
|
82
110
|
};
|
|
83
111
|
|
|
84
112
|
const run = async () => {
|
|
113
|
+
// 다시 시도한 결제를 확인하는 중이면 처음부터 확인 중으로 보인다
|
|
114
|
+
if (round > 0) setView({ kind: "processing" });
|
|
85
115
|
const first = await payments.result(window.location.href).catch(() => null);
|
|
86
116
|
if (!active) return;
|
|
87
|
-
if (first &&
|
|
117
|
+
if (first && settle(first)) return;
|
|
88
118
|
// 결과 확인 중 — 실패로 단정하지 않고 결제 상태를 다시 조회한다
|
|
89
119
|
setView({ kind: "processing" });
|
|
90
120
|
for (let i = 0; i < POLL_TIMES && active; i += 1) {
|
|
@@ -92,7 +122,7 @@ function PaymentReturn() {
|
|
|
92
122
|
if (!active) return;
|
|
93
123
|
const next = await payments.result(window.location.href).catch(() => null);
|
|
94
124
|
if (!active) return;
|
|
95
|
-
if (next &&
|
|
125
|
+
if (next && settle(next)) return;
|
|
96
126
|
}
|
|
97
127
|
if (active) setView({ kind: "unknown" });
|
|
98
128
|
};
|
|
@@ -101,7 +131,35 @@ function PaymentReturn() {
|
|
|
101
131
|
return () => {
|
|
102
132
|
active = false;
|
|
103
133
|
};
|
|
104
|
-
}, [config,
|
|
134
|
+
}, [config, show, round]);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 같은 결제를 다른 옵션으로 다시 시도한다. 클릭 핸들러에서 바로 부른다 — 팝업은 클릭 시점에 열어야 차단되지 않는다.
|
|
138
|
+
* 복귀 주소는 결제 시작 때의 값을 그대로 쓴다. 모바일은 이 탭이 결제 서비스로 이동했다가 다시 이 화면으로 돌아온다.
|
|
139
|
+
*/
|
|
140
|
+
const retry = (paymentId: string, option: AvailablePaymentOption, previous: View) => {
|
|
141
|
+
setRetryError(null);
|
|
142
|
+
setView({ kind: "working" });
|
|
143
|
+
paymentsFor(config)
|
|
144
|
+
.retry(paymentId, { option })
|
|
145
|
+
.then((result) => {
|
|
146
|
+
if (result.status === "PROCESSING") setRound((value) => value + 1);
|
|
147
|
+
else show(result);
|
|
148
|
+
})
|
|
149
|
+
.catch((error: unknown) => {
|
|
150
|
+
// 결제창을 열지 못했다 — 방금 보던 실패 화면으로 되돌린다
|
|
151
|
+
setView(previous);
|
|
152
|
+
setRetryError(retryErrorMessage(error));
|
|
153
|
+
// 더 진행할 수 없는 결제면 옵션을 거두고 주문서로 돌아가게 한다
|
|
154
|
+
if (
|
|
155
|
+
error instanceof ApiError &&
|
|
156
|
+
(error.code === "PAYMENT_EXPIRED" || error.code === "ALREADY_PROCESSED")
|
|
157
|
+
) {
|
|
158
|
+
forgetPaymentOptions(paymentId);
|
|
159
|
+
setOptions([]);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
};
|
|
105
163
|
|
|
106
164
|
return (
|
|
107
165
|
<div className="mx-auto max-w-md space-y-6 py-16 text-center">
|
|
@@ -110,8 +168,8 @@ function PaymentReturn() {
|
|
|
110
168
|
<h1 className="font-bold text-2xl">결제를 확인하고 있습니다</h1>
|
|
111
169
|
<p className="text-muted text-sm">
|
|
112
170
|
{view.kind === "processing"
|
|
113
|
-
? "결제사에서 결과를 확인하는 중입니다. 이 화면을 닫지 말고 잠시만
|
|
114
|
-
: "잠시만
|
|
171
|
+
? "결제사에서 결과를 확인하는 중입니다. 이 화면을 닫지 말고 잠시만 기다려 주십시오."
|
|
172
|
+
: "잠시만 기다려 주십시오."}
|
|
115
173
|
</p>
|
|
116
174
|
</>
|
|
117
175
|
) : view.kind === "failed" ? (
|
|
@@ -120,18 +178,49 @@ function PaymentReturn() {
|
|
|
120
178
|
{view.canceled ? "결제를 취소했습니다" : "결제하지 못했습니다"}
|
|
121
179
|
</h1>
|
|
122
180
|
{view.canceled ? null : <p className="text-muted text-sm">{view.message}</p>}
|
|
123
|
-
|
|
124
|
-
<
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
181
|
+
{options.length ? (
|
|
182
|
+
<section aria-labelledby="retry-heading" className="space-y-2 text-left">
|
|
183
|
+
<h2 id="retry-heading" className="font-bold text-sm">
|
|
184
|
+
다른 결제수단으로 다시 결제
|
|
185
|
+
</h2>
|
|
186
|
+
<div className="grid gap-2">
|
|
187
|
+
{options.map((option) => (
|
|
188
|
+
<button
|
|
189
|
+
key={paymentOptionKey(option)}
|
|
190
|
+
type="button"
|
|
191
|
+
data-testid="retry-option"
|
|
192
|
+
onClick={() => retry(view.paymentId, option, view)}
|
|
193
|
+
className="flex items-center justify-between rounded-md border border-line px-4 py-3 text-sm hover:border-ink"
|
|
194
|
+
>
|
|
195
|
+
<span className="font-medium">{option.label}</span>
|
|
196
|
+
<span className="text-muted text-xs">{option.pgName}</span>
|
|
197
|
+
</button>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
{retryError ? (
|
|
201
|
+
<p role="alert" className="text-point text-sm">
|
|
202
|
+
{retryError}
|
|
203
|
+
</p>
|
|
204
|
+
) : null}
|
|
205
|
+
</section>
|
|
206
|
+
) : null}
|
|
207
|
+
<a
|
|
208
|
+
href={back ?? "/cart"}
|
|
209
|
+
className={
|
|
210
|
+
options.length
|
|
211
|
+
? "inline-block text-sm underline"
|
|
212
|
+
: "inline-block rounded-md bg-ink px-5 py-3 text-white"
|
|
213
|
+
}
|
|
214
|
+
>
|
|
215
|
+
{options.length ? "주문서로 돌아가기" : "주문서로 돌아가 다시 결제"}
|
|
216
|
+
</a>
|
|
128
217
|
</>
|
|
129
218
|
) : (
|
|
130
219
|
<>
|
|
131
220
|
<h1 className="font-bold text-2xl">결제 결과를 확인하지 못했습니다</h1>
|
|
132
221
|
<p className="text-muted text-sm">
|
|
133
222
|
결제를 마쳤다면 잠시 뒤 주문 내역에 나타납니다. 다시 결제하기 전에 주문 내역을 먼저
|
|
134
|
-
|
|
223
|
+
확인해 주십시오.
|
|
135
224
|
</p>
|
|
136
225
|
<Link to="/orders" className="inline-block rounded-md border border-ink px-5 py-3">
|
|
137
226
|
주문 내역
|
|
@@ -141,3 +230,20 @@ function PaymentReturn() {
|
|
|
141
230
|
</div>
|
|
142
231
|
);
|
|
143
232
|
}
|
|
233
|
+
|
|
234
|
+
function retryErrorMessage(error: unknown): string {
|
|
235
|
+
if (error instanceof ApiError) {
|
|
236
|
+
switch (error.code) {
|
|
237
|
+
case "PAYMENT_OPTION_UNAVAILABLE":
|
|
238
|
+
return "지금 쓸 수 없는 결제수단입니다. 다른 결제수단을 골라 주십시오.";
|
|
239
|
+
case "PAYMENT_PROVIDER_UNAVAILABLE":
|
|
240
|
+
return "결제창을 준비하지 못했습니다. 다른 결제수단으로 시도해 주십시오.";
|
|
241
|
+
case "PAYMENT_IN_PROGRESS":
|
|
242
|
+
return "결제를 확인하고 있습니다. 잠시 뒤 주문 내역을 확인해 주십시오.";
|
|
243
|
+
case "PAYMENT_EXPIRED":
|
|
244
|
+
case "ALREADY_PROCESSED":
|
|
245
|
+
return "이 결제는 더 진행할 수 없습니다. 주문서로 돌아가 새로 결제해 주십시오.";
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return "다시 결제하지 못했습니다. 주문서로 돌아가 새로 결제해 주십시오.";
|
|
249
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
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 { orderItemStatusLabel } from "../lib/order-status";
|
|
11
|
+
|
|
12
|
+
const guestOrderSearch = z.object({
|
|
13
|
+
/** 주문 완료 화면이 주문번호를 채워 보낸다 */
|
|
14
|
+
orderId: z.string().optional().catch(undefined),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 비회원 주문 조회 — 주문번호와 주문할 때 적은 연락처·주문 조회 비밀번호로 찾는다.
|
|
19
|
+
* 비밀번호가 주소에 남지 않도록 서버 함수(POST)로 보낸다.
|
|
20
|
+
*/
|
|
21
|
+
const lookupGuestOrder = createServerFn({ method: "POST" })
|
|
22
|
+
.validator(z.object({ orderId: z.string(), phone: z.string(), orderPassword: z.string() }))
|
|
23
|
+
.handler(async ({ data }) => {
|
|
24
|
+
const orderId = data.orderId.trim();
|
|
25
|
+
const phone = data.phone.replace(/[^0-9]/g, "");
|
|
26
|
+
if (!orderId || !phone || !data.orderPassword) {
|
|
27
|
+
return { order: null, error: "주문번호·연락처·비밀번호를 모두 입력해 주십시오" };
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const order: MyOrder = await apiFor().myOrders.getGuestOrder(orderId, {
|
|
31
|
+
phone,
|
|
32
|
+
orderPassword: data.orderPassword,
|
|
33
|
+
});
|
|
34
|
+
return { order, error: null };
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error instanceof ApiError && (error.status === 401 || error.status === 404)) {
|
|
37
|
+
return { order: null, error: "주문을 찾지 못했습니다. 입력한 내용을 확인해 주십시오" };
|
|
38
|
+
}
|
|
39
|
+
return { order: null, error: "조회하지 못했습니다. 잠시 후 다시 시도해 주십시오" };
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const Route = createFileRoute("/guest-order")({
|
|
44
|
+
validateSearch: guestOrderSearch,
|
|
45
|
+
head: () => ({ meta: [{ title: "비회원 주문 조회" }] }),
|
|
46
|
+
component: GuestOrder,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function GuestOrder() {
|
|
50
|
+
const { orderId } = Route.useSearch();
|
|
51
|
+
const [order, setOrder] = useState<MyOrder | null>(null);
|
|
52
|
+
const [error, setError] = useState<string | null>(null);
|
|
53
|
+
const [pending, setPending] = useState(false);
|
|
54
|
+
|
|
55
|
+
const submit = (event: React.FormEvent<HTMLFormElement>) => {
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
const form = new FormData(event.currentTarget);
|
|
58
|
+
setPending(true);
|
|
59
|
+
setError(null);
|
|
60
|
+
void lookupGuestOrder({
|
|
61
|
+
data: {
|
|
62
|
+
orderId: String(form.get("orderId") ?? ""),
|
|
63
|
+
phone: String(form.get("phone") ?? ""),
|
|
64
|
+
orderPassword: String(form.get("orderPassword") ?? ""),
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
.then((result) => {
|
|
68
|
+
setOrder(result.order);
|
|
69
|
+
setError(result.error);
|
|
70
|
+
})
|
|
71
|
+
.catch(() => {
|
|
72
|
+
setOrder(null);
|
|
73
|
+
setError("조회하지 못했습니다. 잠시 후 다시 시도해 주십시오");
|
|
74
|
+
})
|
|
75
|
+
.finally(() => setPending(false));
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="mx-auto max-w-lg space-y-6 py-10">
|
|
80
|
+
<div className="space-y-1">
|
|
81
|
+
<h1 className="font-bold text-2xl">비회원 주문 조회</h1>
|
|
82
|
+
<p className="text-muted text-sm">
|
|
83
|
+
주문할 때 입력한 연락처와 주문 조회 비밀번호를 넣어 주십시오.
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<form onSubmit={submit} className="space-y-3">
|
|
88
|
+
<Field label="주문번호" name="orderId" defaultValue={orderId} placeholder="ord_" />
|
|
89
|
+
<Field label="연락처" name="phone" type="tel" placeholder="01012345678" />
|
|
90
|
+
<Field label="주문 조회 비밀번호" name="orderPassword" type="password" />
|
|
91
|
+
<SubmitButton
|
|
92
|
+
disabled={pending}
|
|
93
|
+
className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
|
|
94
|
+
>
|
|
95
|
+
조회하기
|
|
96
|
+
</SubmitButton>
|
|
97
|
+
</form>
|
|
98
|
+
{error ? (
|
|
99
|
+
<p role="alert" className="text-point text-sm">
|
|
100
|
+
{error}
|
|
101
|
+
</p>
|
|
102
|
+
) : null}
|
|
103
|
+
|
|
104
|
+
{order ? (
|
|
105
|
+
<section aria-label="주문 정보" className="space-y-3 rounded-lg border border-line p-5">
|
|
106
|
+
<div className="space-y-1">
|
|
107
|
+
<h2 className="font-bold text-lg">주문 {order.orderId}</h2>
|
|
108
|
+
<p className="text-muted text-sm">{formatDateTime(order.orderedAt)}</p>
|
|
109
|
+
</div>
|
|
110
|
+
<ul className="divide-y divide-line">
|
|
111
|
+
{order.items.map((item) => (
|
|
112
|
+
<li key={item.orderItemId} className="flex items-center gap-3 py-3 text-sm">
|
|
113
|
+
<ProductThumb
|
|
114
|
+
src={item.thumbnailUrl}
|
|
115
|
+
alt={item.productName}
|
|
116
|
+
className="size-14 rounded-md bg-chip object-cover"
|
|
117
|
+
/>
|
|
118
|
+
<span className="flex flex-1 flex-col gap-0.5">
|
|
119
|
+
<span>{item.productName}</span>
|
|
120
|
+
{item.optionName ? (
|
|
121
|
+
<span className="text-muted text-xs">{item.optionName}</span>
|
|
122
|
+
) : null}
|
|
123
|
+
<span className="text-muted text-xs">
|
|
124
|
+
{orderItemStatusLabel(item.status)} · 수량 {item.quantity}개
|
|
125
|
+
</span>
|
|
126
|
+
</span>
|
|
127
|
+
<span>{formatPrice(item.totalPrice)}</span>
|
|
128
|
+
</li>
|
|
129
|
+
))}
|
|
130
|
+
</ul>
|
|
131
|
+
<div className="flex justify-between border-line border-t pt-2 font-bold text-sm">
|
|
132
|
+
<span>결제 금액</span>
|
|
133
|
+
<span>{formatPrice(order.payment.totalAmount)}</span>
|
|
134
|
+
</div>
|
|
135
|
+
<p className="text-muted text-xs">
|
|
136
|
+
취소·반품은 스토어 고객센터로 문의해 주십시오. 회원으로 주문하면 주문 내역에서 직접
|
|
137
|
+
신청할 수 있습니다.
|
|
138
|
+
</p>
|
|
139
|
+
</section>
|
|
140
|
+
) : null}
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function Field({
|
|
146
|
+
label,
|
|
147
|
+
name,
|
|
148
|
+
type = "text",
|
|
149
|
+
defaultValue,
|
|
150
|
+
placeholder,
|
|
151
|
+
}: {
|
|
152
|
+
label: string;
|
|
153
|
+
name: string;
|
|
154
|
+
type?: string;
|
|
155
|
+
defaultValue?: string;
|
|
156
|
+
placeholder?: string;
|
|
157
|
+
}) {
|
|
158
|
+
return (
|
|
159
|
+
<label className="block space-y-1">
|
|
160
|
+
<span className="text-sm">{label}</span>
|
|
161
|
+
<input
|
|
162
|
+
type={type}
|
|
163
|
+
name={name}
|
|
164
|
+
defaultValue={defaultValue}
|
|
165
|
+
placeholder={placeholder}
|
|
166
|
+
required
|
|
167
|
+
className="h-11 w-full rounded-md border border-line px-3"
|
|
168
|
+
/>
|
|
169
|
+
</label>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -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";
|
|
@@ -174,14 +174,18 @@ function Login() {
|
|
|
174
174
|
<input
|
|
175
175
|
type="email"
|
|
176
176
|
name="email"
|
|
177
|
+
aria-label="이메일"
|
|
177
178
|
placeholder="이메일"
|
|
179
|
+
autoComplete="email"
|
|
178
180
|
required
|
|
179
181
|
className="h-11 w-full rounded-md border border-line px-3"
|
|
180
182
|
/>
|
|
181
183
|
<input
|
|
182
184
|
type="password"
|
|
183
185
|
name="password"
|
|
186
|
+
aria-label="비밀번호"
|
|
184
187
|
placeholder="비밀번호"
|
|
188
|
+
autoComplete="current-password"
|
|
185
189
|
required
|
|
186
190
|
className="h-11 w-full rounded-md border border-line px-3"
|
|
187
191
|
/>
|
|
@@ -193,10 +197,25 @@ function Login() {
|
|
|
193
197
|
</SubmitButton>
|
|
194
198
|
</form>
|
|
195
199
|
) : null}
|
|
196
|
-
{error ?
|
|
200
|
+
{error ? (
|
|
201
|
+
<p role="alert" className="text-point text-sm">
|
|
202
|
+
{error}
|
|
203
|
+
</p>
|
|
204
|
+
) : null}
|
|
197
205
|
{social.length ? (
|
|
198
206
|
<p className="text-center text-muted text-xs">처음 소셜 로그인하면 가입이 함께 됩니다</p>
|
|
199
207
|
) : null}
|
|
208
|
+
<div className="flex justify-center gap-4 text-sm">
|
|
209
|
+
{/* 이메일·비밀번호 가입은 그 로그인 방법이 켜진 스토어에서만 된다 */}
|
|
210
|
+
{password ? (
|
|
211
|
+
<Link to="/signup" search={{ redirectTo }} className="underline">
|
|
212
|
+
회원가입
|
|
213
|
+
</Link>
|
|
214
|
+
) : null}
|
|
215
|
+
<Link to="/guest-order" className="text-muted underline">
|
|
216
|
+
비회원 주문 조회
|
|
217
|
+
</Link>
|
|
218
|
+
</div>
|
|
200
219
|
</div>
|
|
201
220
|
);
|
|
202
221
|
}
|
|
@@ -15,6 +15,7 @@ import { ProductThumb } from "../components/product-thumb";
|
|
|
15
15
|
import { SubmitButton } from "../components/submit-button";
|
|
16
16
|
import { apiFor } from "../lib/api.server";
|
|
17
17
|
import { formatDateTime, formatPrice } from "../lib/format";
|
|
18
|
+
import { orderItemStatusLabel } from "../lib/order-status";
|
|
18
19
|
import { readToken } from "../lib/session.server";
|
|
19
20
|
|
|
20
21
|
const getOrder = createServerFn({ method: "GET" })
|
|
@@ -62,18 +63,18 @@ const createClaim = createServerFn({ method: "POST" })
|
|
|
62
63
|
});
|
|
63
64
|
|
|
64
65
|
function claimErrorMessage(error: unknown): string {
|
|
65
|
-
if (!(error instanceof ApiError)) return "접수하지 못했습니다. 잠시 후 다시
|
|
66
|
+
if (!(error instanceof ApiError)) return "접수하지 못했습니다. 잠시 후 다시 시도해 주십시오.";
|
|
66
67
|
switch (error.code) {
|
|
67
68
|
case "INVALID_QUANTITY":
|
|
68
|
-
return "남은 수량보다 많이 신청했습니다. 수량을
|
|
69
|
+
return "남은 수량보다 많이 신청했습니다. 수량을 확인해 주십시오.";
|
|
69
70
|
case "CLAIM_IN_PROGRESS":
|
|
70
71
|
return "이미 진행 중인 신청이 있습니다.";
|
|
71
72
|
case "INVALID_STATUS":
|
|
72
|
-
return "지금은 신청할 수 없는 상태입니다. 주문 상태를
|
|
73
|
+
return "지금은 신청할 수 없는 상태입니다. 주문 상태를 확인해 주십시오.";
|
|
73
74
|
case "PERIOD_EXPIRED":
|
|
74
75
|
return "반품·교환 가능 기간이 지났습니다.";
|
|
75
76
|
default:
|
|
76
|
-
return error.message || "접수하지 못했습니다. 잠시 후 다시
|
|
77
|
+
return error.message || "접수하지 못했습니다. 잠시 후 다시 시도해 주십시오.";
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
|
|
@@ -171,6 +172,7 @@ function OrderDetail() {
|
|
|
171
172
|
<div className="flex items-center gap-4">
|
|
172
173
|
<ProductThumb
|
|
173
174
|
src={item.thumbnailUrl}
|
|
175
|
+
alt={item.productName}
|
|
174
176
|
className="size-16 rounded-md bg-chip object-cover"
|
|
175
177
|
/>
|
|
176
178
|
<span className="flex flex-1 flex-col gap-1 text-sm">
|
|
@@ -179,7 +181,7 @@ function OrderDetail() {
|
|
|
179
181
|
<span className="text-muted text-xs">{item.optionName}</span>
|
|
180
182
|
) : null}
|
|
181
183
|
<span className="text-muted text-xs">
|
|
182
|
-
{item.status} · 수량 {item.quantity}개
|
|
184
|
+
{orderItemStatusLabel(item.status)} · 수량 {item.quantity}개
|
|
183
185
|
{item.canceledQuantity > 0
|
|
184
186
|
? ` · 취소·반품 ${item.canceledQuantity}개 · 남은 수량 ${item.activeQuantity}개`
|
|
185
187
|
: null}
|
|
@@ -271,9 +273,9 @@ function ClaimForm({
|
|
|
271
273
|
const quantity = Number(quantityText);
|
|
272
274
|
const quantityError =
|
|
273
275
|
quantityText.trim() === "" || !Number.isInteger(quantity)
|
|
274
|
-
? "수량을
|
|
276
|
+
? "수량을 입력해 주십시오."
|
|
275
277
|
: quantity < 1 || quantity > item.activeQuantity
|
|
276
|
-
? `수량은 1개 이상 ${item.activeQuantity}개 이하로
|
|
278
|
+
? `수량은 1개 이상 ${item.activeQuantity}개 이하로 입력해 주십시오.`
|
|
277
279
|
: null;
|
|
278
280
|
return (
|
|
279
281
|
<form
|
|
@@ -75,12 +75,16 @@ function ProductDetail() {
|
|
|
75
75
|
return (
|
|
76
76
|
<div className="grid gap-8 md:grid-cols-2">
|
|
77
77
|
<div className="space-y-3">
|
|
78
|
-
<ProductThumb
|
|
79
|
-
|
|
78
|
+
<ProductThumb
|
|
79
|
+
src={product.thumbnailUrl}
|
|
80
|
+
alt={product.name}
|
|
81
|
+
className="w-full rounded-lg bg-chip"
|
|
82
|
+
/>
|
|
83
|
+
{product.images.slice(0, 4).map((image, index) => (
|
|
80
84
|
<img
|
|
81
85
|
key={image}
|
|
82
86
|
src={image}
|
|
83
|
-
alt=
|
|
87
|
+
alt={`${product.name} 이미지 ${index + 1}`}
|
|
84
88
|
className="w-full rounded-lg bg-chip"
|
|
85
89
|
loading="lazy"
|
|
86
90
|
/>
|
|
@@ -107,7 +111,7 @@ function ProductDetail() {
|
|
|
107
111
|
setError(null);
|
|
108
112
|
addToCart({ data: { productId: product.productId, optionId, quantity } })
|
|
109
113
|
.then(() => navigate({ to: "/cart" }))
|
|
110
|
-
.catch(() => setError("장바구니에 담지 못했습니다. 다시
|
|
114
|
+
.catch(() => setError("장바구니에 담지 못했습니다. 다시 시도해 주십시오"))
|
|
111
115
|
.finally(() => setSubmitting(false));
|
|
112
116
|
}}
|
|
113
117
|
>
|
|
@@ -54,6 +54,9 @@ function Products() {
|
|
|
54
54
|
|
|
55
55
|
return (
|
|
56
56
|
<div className="space-y-6">
|
|
57
|
+
<h1 className="font-bold text-2xl">
|
|
58
|
+
{applied.keyword ? `"${applied.keyword}" 검색 결과` : "전체 상품"}
|
|
59
|
+
</h1>
|
|
57
60
|
{/* 하이드레이션 전에는 GET 폼으로 그대로 제출되고, 뒤에는 화면을 새로 그리지 않고 이동한다 */}
|
|
58
61
|
<form
|
|
59
62
|
action="/products"
|
|
@@ -70,6 +73,7 @@ function Products() {
|
|
|
70
73
|
<input
|
|
71
74
|
type="search"
|
|
72
75
|
name="keyword"
|
|
76
|
+
aria-label="상품 검색"
|
|
73
77
|
defaultValue={applied.keyword ?? ""}
|
|
74
78
|
placeholder="상품 검색"
|
|
75
79
|
className="h-11 flex-1 rounded-md border border-line px-3"
|