@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.
@@ -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
- useEffect(() => {
59
- let active = true;
60
- const payments = paymentsFor(config);
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
- const show = (result: PaymentResult): boolean => {
76
+ /** 확정된 결과면 화면을 정하고 true. `PROCESSING`이면 false — 실패로 단정하지 않는다 */
77
+ const show = useCallback(
78
+ (result: PaymentResult): boolean => {
65
79
  if (result.status === "COMPLETED") {
66
- done(result.orderId);
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 && show(first)) return;
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 && show(next)) return;
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, navigate]);
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
- <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>
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
- 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";
@@ -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 ? <p className="text-point text-sm">{error}</p> : null}
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 src={product.thumbnailUrl} className="w-full rounded-lg bg-chip" />
79
- {product.images.slice(0, 4).map((image) => (
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"