@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.
Files changed (32) hide show
  1. package/dist/index.mjs +33 -3
  2. package/package.json +3 -3
  3. package/template/.template-meta.json +3 -2
  4. package/template/README.md +87 -55
  5. package/template/package.json +2 -2
  6. package/template/public/favicon.svg +1 -0
  7. package/template/src/components/product-card.tsx +2 -0
  8. package/template/src/components/site-header.tsx +16 -6
  9. package/template/src/components/submit-button.tsx +4 -0
  10. package/template/src/lib/config.server.ts +1 -1
  11. package/template/src/lib/guest-order.test.ts +18 -0
  12. package/template/src/lib/guest-order.ts +13 -0
  13. package/template/src/lib/order-status.ts +17 -0
  14. package/template/src/lib/page-title.test.ts +19 -0
  15. package/template/src/lib/page-title.ts +23 -0
  16. package/template/src/lib/payment-options.test.ts +61 -0
  17. package/template/src/lib/payment-options.ts +39 -0
  18. package/template/src/routeTree.gen.ts +42 -0
  19. package/template/src/routes/__root.tsx +40 -6
  20. package/template/src/routes/account.tsx +166 -4
  21. package/template/src/routes/cart.tsx +9 -2
  22. package/template/src/routes/checkout.complete.tsx +22 -10
  23. package/template/src/routes/checkout.index.tsx +40 -17
  24. package/template/src/routes/checkout.return.tsx +132 -20
  25. package/template/src/routes/guest-order.tsx +183 -0
  26. package/template/src/routes/index.tsx +2 -3
  27. package/template/src/routes/login.tsx +23 -3
  28. package/template/src/routes/orders.$orderId.tsx +13 -8
  29. package/template/src/routes/orders.index.tsx +2 -1
  30. package/template/src/routes/products.$productId.tsx +12 -5
  31. package/template/src/routes/products.index.tsx +6 -1
  32. 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 ? <p className="rounded-md bg-chip px-3 py-2 text-sm">{notice}</p> : null}
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 ? <span className="text-point"> *</span> : null}
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 ? <span className="text-muted text-xs">{hint}</span> : null}
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: () => ({ meta: [{ title: "결제 확인" }, { name: "referrer", content: "no-referrer" }] }),
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
- useEffect(() => {
59
- let active = true;
60
- const payments = paymentsFor(config);
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
- const show = (result: PaymentResult): boolean => {
82
+ /** 확정된 결과면 화면을 정하고 true. `PROCESSING`이면 false — 실패로 단정하지 않는다 */
83
+ const show = useCallback(
84
+ (result: PaymentResult): boolean => {
65
85
  if (result.status === "COMPLETED") {
66
- done(result.orderId);
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 && show(first)) return;
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 && show(next)) return;
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, navigate]);
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
- <div className="flex justify-center gap-2">
124
- <a href={back ?? "/cart"} className="rounded-md bg-ink px-5 py-3 text-white">
125
- 다른 결제수단으로 다시 결제
126
- </a>
127
- </div>
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
- head: () => ({
24
- meta: [{ title: "스토어" }, { name: "description", content: "지금 인기 있는 상품" }],
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 ? <p className="text-point text-sm">{error}</p> : null}
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
  }