@sayren/mcp 0.6.1 → 0.8.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,14 +1,26 @@
1
+ import { zodResolver } from "@hookform/resolvers/zod";
1
2
  import { ApiError, type MyOrder } from "@sayren/storefront-sdk";
2
3
  import { createFileRoute } from "@tanstack/react-router";
3
4
  import { createServerFn } from "@tanstack/react-start";
4
5
  import { useState } from "react";
6
+ import { useForm } from "react-hook-form";
5
7
  import { z } from "zod";
8
+ import { FulfillmentCodes } from "../components/fulfillment-codes";
9
+ import { FulfillmentDownloads } from "../components/fulfillment-downloads";
6
10
  import { ProductThumb } from "../components/product-thumb";
7
11
  import { SubmitButton } from "../components/submit-button";
12
+ import { TextField } from "../components/text-field";
8
13
  import { apiFor } from "../lib/api.server";
14
+ import { type CodeRevealResult, codeRevealErrorMessage, normalizePhone } from "../lib/code-reveal";
15
+ import { type DownloadResult, downloadErrorMessage } from "../lib/download";
16
+ import {
17
+ type GuestOrderLookup,
18
+ type GuestOrderLookupInput,
19
+ guestOrderLookupSchema,
20
+ } from "../lib/form-schemas";
9
21
  import { formatDateTime, formatPrice } from "../lib/format";
10
22
  import { guestLookupLockedMessage } from "../lib/guest-order";
11
- import { orderItemStatusLabel } from "../lib/order-status";
23
+ import { orderItemStatusLabel, withdrawalRestrictedLabel } from "../lib/order-status";
12
24
  import { pageTitle } from "../lib/page-title";
13
25
 
14
26
  const guestOrderSearch = z.object({
@@ -24,7 +36,7 @@ const lookupGuestOrder = createServerFn({ method: "POST" })
24
36
  .validator(z.object({ orderId: z.string(), phone: z.string(), orderPassword: z.string() }))
25
37
  .handler(async ({ data }) => {
26
38
  const orderId = data.orderId.trim();
27
- const phone = data.phone.replace(/[^0-9]/g, "");
39
+ const phone = normalizePhone(data.phone);
28
40
  if (!orderId || !phone || !data.orderPassword) {
29
41
  return { order: null, error: "주문번호·연락처·비밀번호를 모두 입력해 주십시오" };
30
42
  }
@@ -46,6 +58,58 @@ const lookupGuestOrder = createServerFn({ method: "POST" })
46
58
  }
47
59
  });
48
60
 
61
+ /**
62
+ * 비회원 코드 열람 — 조회에 쓴 연락처·주문 조회 비밀번호로 다시 확인한다(주문 조회와 같은 시도 한도).
63
+ * 비밀번호는 서버 함수 본문(POST)으로만 보내고 주소에 남기지 않는다.
64
+ */
65
+ const revealGuestCode = createServerFn({ method: "POST" })
66
+ .validator(
67
+ z.object({
68
+ orderId: z.string(),
69
+ orderItemId: z.string(),
70
+ phone: z.string(),
71
+ orderPassword: z.string(),
72
+ }),
73
+ )
74
+ .handler(async ({ data }): Promise<CodeRevealResult> => {
75
+ try {
76
+ const result = await apiFor().myOrders.revealGuestCode(data.orderId, data.orderItemId, {
77
+ phone: normalizePhone(data.phone),
78
+ orderPassword: data.orderPassword,
79
+ });
80
+ return { codes: result.codes, error: null };
81
+ } catch (error) {
82
+ return { codes: null, error: codeRevealErrorMessage(error) };
83
+ }
84
+ });
85
+
86
+ /**
87
+ * 비회원 파일 받기 — 조회에 쓴 연락처·주문 조회 비밀번호로 다시 확인하고 서명 주소(수명 300초)를 받는다. 받을 때마다
88
+ * 횟수를 하나 쓴다. 비밀번호는 서버 함수 본문(POST)으로만 보내고 주소에 남기지 않는다.
89
+ */
90
+ const createGuestDownload = createServerFn({ method: "POST" })
91
+ .validator(
92
+ z.object({
93
+ orderId: z.string(),
94
+ orderItemId: z.string(),
95
+ assetId: z.string(),
96
+ phone: z.string(),
97
+ orderPassword: z.string(),
98
+ }),
99
+ )
100
+ .handler(async ({ data }): Promise<DownloadResult> => {
101
+ try {
102
+ const link = await apiFor().myOrders.createGuestDownload(data.orderId, data.orderItemId, {
103
+ phone: normalizePhone(data.phone),
104
+ orderPassword: data.orderPassword,
105
+ assetId: data.assetId,
106
+ });
107
+ return { url: link.url, fileName: link.fileName, remaining: link.remaining, error: null };
108
+ } catch (error) {
109
+ return { url: null, error: downloadErrorMessage(error) };
110
+ }
111
+ });
112
+
49
113
  export const Route = createFileRoute("/guest-order")({
50
114
  validateSearch: guestOrderSearch,
51
115
  head: ({ matches }) => ({
@@ -61,31 +125,27 @@ export const Route = createFileRoute("/guest-order")({
61
125
  function GuestOrder() {
62
126
  const { orderId } = Route.useSearch();
63
127
  const [order, setOrder] = useState<MyOrder | null>(null);
128
+ // 코드 열람에 다시 쓰는 조회 값 — 이 컴포넌트 상태에만 둔다(주소·스토리지에 남기지 않는다)
129
+ const [lookup, setLookup] = useState<GuestOrderLookup | null>(null);
64
130
  const [error, setError] = useState<string | null>(null);
65
- const [pending, setPending] = useState(false);
131
+ const form = useForm<GuestOrderLookupInput, unknown, GuestOrderLookup>({
132
+ resolver: zodResolver(guestOrderLookupSchema),
133
+ });
134
+ const { errors, isSubmitting } = form.formState;
66
135
 
67
- const submit = (event: React.FormEvent<HTMLFormElement>) => {
68
- event.preventDefault();
69
- const form = new FormData(event.currentTarget);
70
- setPending(true);
136
+ const submit = form.handleSubmit(async (values) => {
71
137
  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
- };
138
+ try {
139
+ const result = await lookupGuestOrder({ data: values });
140
+ setOrder(result.order);
141
+ setLookup(result.order ? values : null);
142
+ setError(result.error);
143
+ } catch {
144
+ setOrder(null);
145
+ setLookup(null);
146
+ setError("조회하지 못했습니다. 잠시 후 다시 시도해 주십시오");
147
+ }
148
+ });
89
149
 
90
150
  return (
91
151
  <div className="mx-auto max-w-lg space-y-6 py-10">
@@ -97,11 +157,32 @@ function GuestOrder() {
97
157
  </div>
98
158
 
99
159
  <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" />
160
+ {/* 주문 완료 화면이 주문번호를 채워 보낸다 — 서버 HTML에 값이 실리도록 defaultValue로 준다 */}
161
+ <TextField
162
+ label="주문번호"
163
+ registration={form.register("orderId")}
164
+ error={errors.orderId?.message}
165
+ defaultValue={orderId}
166
+ placeholder="ord_"
167
+ required
168
+ />
169
+ <TextField
170
+ label="연락처"
171
+ registration={form.register("phone")}
172
+ error={errors.phone?.message}
173
+ type="tel"
174
+ placeholder="01012345678"
175
+ required
176
+ />
177
+ <TextField
178
+ label="주문 조회 비밀번호"
179
+ registration={form.register("orderPassword")}
180
+ error={errors.orderPassword?.message}
181
+ type="password"
182
+ required
183
+ />
103
184
  <SubmitButton
104
- disabled={pending}
185
+ disabled={isSubmitting}
105
186
  className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
106
187
  >
107
188
  조회하기
@@ -121,22 +202,59 @@ function GuestOrder() {
121
202
  </div>
122
203
  <ul className="divide-y divide-line">
123
204
  {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}개
205
+ <li key={item.orderItemId} className="space-y-2 py-3 text-sm">
206
+ <div className="flex items-center gap-3">
207
+ <ProductThumb
208
+ src={item.thumbnailUrl}
209
+ alt={item.productName}
210
+ className="size-14 rounded-md bg-chip object-cover"
211
+ />
212
+ <span className="flex flex-1 flex-col gap-0.5">
213
+ <span>{item.productName}</span>
214
+ {item.optionName ? (
215
+ <span className="text-muted text-xs">{item.optionName}</span>
216
+ ) : null}
217
+ <span className="text-muted text-xs">
218
+ {orderItemStatusLabel(item.status, item.fulfillmentType)} · 수량{" "}
219
+ {item.quantity}개
220
+ </span>
221
+ {withdrawalRestrictedLabel(item) ? (
222
+ <span className="text-muted text-xs">{withdrawalRestrictedLabel(item)}</span>
223
+ ) : null}
137
224
  </span>
138
- </span>
139
- <span>{formatPrice(item.totalPrice)}</span>
225
+ <span>{formatPrice(item.totalPrice)}</span>
226
+ </div>
227
+ {lookup ? (
228
+ <FulfillmentCodes
229
+ item={item}
230
+ reveal={() =>
231
+ revealGuestCode({
232
+ data: {
233
+ orderId: order.orderId,
234
+ orderItemId: item.orderItemId,
235
+ phone: lookup.phone,
236
+ orderPassword: lookup.orderPassword,
237
+ },
238
+ })
239
+ }
240
+ />
241
+ ) : null}
242
+ {lookup ? (
243
+ <FulfillmentDownloads
244
+ item={item}
245
+ download={(assetId) =>
246
+ createGuestDownload({
247
+ data: {
248
+ orderId: order.orderId,
249
+ orderItemId: item.orderItemId,
250
+ assetId,
251
+ phone: lookup.phone,
252
+ orderPassword: lookup.orderPassword,
253
+ },
254
+ })
255
+ }
256
+ />
257
+ ) : null}
140
258
  </li>
141
259
  ))}
142
260
  </ul>
@@ -153,31 +271,3 @@ function GuestOrder() {
153
271
  </div>
154
272
  );
155
273
  }
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
- }
@@ -11,11 +11,19 @@ import { createFileRoute, Link, notFound, redirect, useRouter } from "@tanstack/
11
11
  import { createServerFn } from "@tanstack/react-start";
12
12
  import { useState } from "react";
13
13
  import { z } from "zod";
14
+ import { FulfillmentCodes } from "../components/fulfillment-codes";
15
+ import { FulfillmentDownloads } from "../components/fulfillment-downloads";
14
16
  import { ProductThumb } from "../components/product-thumb";
15
17
  import { SubmitButton } from "../components/submit-button";
16
18
  import { apiFor } from "../lib/api.server";
19
+ import { type CodeRevealResult, codeRevealErrorMessage } from "../lib/code-reveal";
20
+ import { type DownloadResult, downloadErrorMessage } from "../lib/download";
17
21
  import { formatDateTime, formatPrice } from "../lib/format";
18
- import { orderItemStatusLabel } from "../lib/order-status";
22
+ import {
23
+ orderItemStatusLabel,
24
+ WITHDRAWAL_RESTRICTED_CLAIM_MESSAGE,
25
+ withdrawalRestrictedLabel,
26
+ } from "../lib/order-status";
19
27
  import { pageTitle } from "../lib/page-title";
20
28
  import { readToken } from "../lib/session.server";
21
29
 
@@ -34,6 +42,42 @@ const getOrder = createServerFn({ method: "GET" })
34
42
  }
35
43
  });
36
44
 
45
+ /**
46
+ * 코드 열람 — 코드 발급 상품의 평문 코드를 받는다. 처음 연 시각이 기록된다. 구매자 토큰은 서버에만 두므로 서버
47
+ * 함수에서 부르고, 평문은 응답으로만 돌려준다(저장하지 않는다).
48
+ */
49
+ const revealCode = createServerFn({ method: "POST" })
50
+ .validator(z.object({ orderItemId: z.string() }))
51
+ .handler(async ({ data }): Promise<CodeRevealResult> => {
52
+ const accessToken = readToken();
53
+ if (!accessToken) throw redirect({ to: "/login", search: { redirectTo: "/orders" } });
54
+ try {
55
+ const result = await apiFor({ accessToken }).myOrders.revealCode(data.orderItemId);
56
+ return { codes: result.codes, error: null };
57
+ } catch (error) {
58
+ return { codes: null, error: codeRevealErrorMessage(error) };
59
+ }
60
+ });
61
+
62
+ /**
63
+ * 파일 받기 — 파일 다운로드 상품의 서명 주소(수명 300초)를 받는다. 받을 때마다 횟수를 하나 쓴다. 구매자 토큰은 서버에만
64
+ * 두므로 서버 함수에서 부르고, 주소는 응답으로만 돌려준다(저장하지 않는다).
65
+ */
66
+ const createDownload = createServerFn({ method: "POST" })
67
+ .validator(z.object({ orderItemId: z.string(), assetId: z.string() }))
68
+ .handler(async ({ data }): Promise<DownloadResult> => {
69
+ const accessToken = readToken();
70
+ if (!accessToken) throw redirect({ to: "/login", search: { redirectTo: "/orders" } });
71
+ try {
72
+ const link = await apiFor({ accessToken }).myOrders.createDownload(data.orderItemId, {
73
+ assetId: data.assetId,
74
+ });
75
+ return { url: link.url, fileName: link.fileName, remaining: link.remaining, error: null };
76
+ } catch (error) {
77
+ return { url: null, error: downloadErrorMessage(error) };
78
+ }
79
+ });
80
+
37
81
  /**
38
82
  * 취소·반품 접수 — 수량을 실으면 그 수량만 접수된다(생략하면 남은 수량 전체).
39
83
  * 상한은 주문 상품의 `activeQuantity`이고 넘기면 서버가 400 `INVALID_QUANTITY`로 거절한다.
@@ -74,6 +118,8 @@ function claimErrorMessage(error: unknown): string {
74
118
  return "지금은 신청할 수 없는 상태입니다. 주문 상태를 확인해 주십시오.";
75
119
  case "PERIOD_EXPIRED":
76
120
  return "반품·교환 가능 기간이 지났습니다.";
121
+ case "WITHDRAWAL_RESTRICTED":
122
+ return WITHDRAWAL_RESTRICTED_CLAIM_MESSAGE;
77
123
  default:
78
124
  return error.message || "접수하지 못했습니다. 잠시 후 다시 시도해 주십시오.";
79
125
  }
@@ -184,15 +230,30 @@ function OrderDetail() {
184
230
  <span className="text-muted text-xs">{item.optionName}</span>
185
231
  ) : null}
186
232
  <span className="text-muted text-xs">
187
- {orderItemStatusLabel(item.status)} · 수량 {item.quantity}개
233
+ {orderItemStatusLabel(item.status, item.fulfillmentType)} · 수량 {item.quantity}개
188
234
  {item.canceledQuantity > 0
189
235
  ? ` · 취소·반품 ${item.canceledQuantity}개 · 남은 수량 ${item.activeQuantity}개`
190
236
  : null}
191
237
  </span>
238
+ {withdrawalRestrictedLabel(item) ? (
239
+ <span className="text-muted text-xs">{withdrawalRestrictedLabel(item)}</span>
240
+ ) : null}
192
241
  </span>
193
242
  <span className="text-sm">{formatPrice(item.totalPrice)}</span>
194
243
  </div>
195
244
 
245
+ <FulfillmentInfo item={item} />
246
+ <FulfillmentCodes
247
+ item={item}
248
+ reveal={() => revealCode({ data: { orderItemId: item.orderItemId } })}
249
+ />
250
+ <FulfillmentDownloads
251
+ item={item}
252
+ download={(assetId) =>
253
+ createDownload({ data: { orderItemId: item.orderItemId, assetId } })
254
+ }
255
+ />
256
+
196
257
  {claimableTypes(item.status).length > 0 &&
197
258
  !item.claimStatus &&
198
259
  item.activeQuantity > 0 ? (
@@ -247,6 +308,37 @@ function OrderDetail() {
247
308
  );
248
309
  }
249
310
 
311
+ /**
312
+ * 배송이 없는 상품의 받는 방법 — 방문 수령 장소와 셀러가 제공 처리하며 남긴 이용 안내(이슈 #48).
313
+ * 배송 상품이거나 보여 줄 것이 없으면 그리지 않는다. 배송 상품이 아니므로 배송 조회 링크가 없다.
314
+ */
315
+ function FulfillmentInfo({ item }: { item: MyOrderItem }) {
316
+ const pickup = item.fulfillmentType === "PICKUP" ? item.pickupLocation : null;
317
+ const note = item.fulfillment?.status === "FULFILLED" ? item.fulfillment.note : null;
318
+ if (!pickup && !note) return null;
319
+ return (
320
+ <dl className="space-y-2 rounded-md bg-chip p-3 text-xs">
321
+ {pickup ? (
322
+ <div className="space-y-0.5">
323
+ <dt className="font-semibold">수령 장소</dt>
324
+ <dd>{pickup.name}</dd>
325
+ <dd className="text-muted">
326
+ ({pickup.zipCode}) {pickup.address1}
327
+ {pickup.address2 ? ` ${pickup.address2}` : ""}
328
+ </dd>
329
+ <dd className="text-muted">{pickup.phone}</dd>
330
+ </div>
331
+ ) : null}
332
+ {note ? (
333
+ <div className="space-y-0.5">
334
+ <dt className="font-semibold">이용 안내</dt>
335
+ <dd className="whitespace-pre-line">{note}</dd>
336
+ </div>
337
+ ) : null}
338
+ </dl>
339
+ );
340
+ }
341
+
250
342
  /**
251
343
  * 취소·반품 접수 폼. 수량 기본값은 남은 수량 전체이고 입력값은 `activeQuantity`까지다.
252
344
  * 서버 렌더한 마크업에 값이 그대로 남아야 하므로 프리필은 `defaultValue`로 준다.
@@ -1,43 +1,33 @@
1
- import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
2
- import { createServerFn } from "@tanstack/react-start";
3
- import { z } from "zod";
1
+ import { productSortSchema } from "@sayren/storefront-sdk";
2
+ import { useSuspenseQuery } from "@tanstack/react-query";
3
+ import { createFileRoute, Link } from "@tanstack/react-router";
4
+ import { useQueryStates } from "nuqs";
4
5
  import { ProductCard } from "../components/product-card";
5
6
  import { useTrack } from "../lib/analytics";
6
- import { apiFor } from "../lib/api.server";
7
+ import { categoriesQuery, productsQuery } from "../lib/catalog-queries";
7
8
  import { pageTitle } from "../lib/page-title";
8
-
9
- /** 목록·검색 조건 — URL에 둔다. 공유·뒤로가기가 그대로 동작한다 */
10
- const productsSearch = z.object({
11
- keyword: z.string().optional().catch(undefined),
12
- categoryId: z.string().optional().catch(undefined),
13
- sort: z.enum(["recommend", "latest"]).optional().catch(undefined),
14
- page: z.coerce.number().int().min(1).optional().catch(undefined),
15
- });
16
- type ProductsSearch = z.infer<typeof productsSearch>;
17
-
18
- const searchProducts = createServerFn({ method: "GET" })
19
- .validator(productsSearch)
20
- .handler(async ({ data }) => {
21
- const api = apiFor();
22
- const [categories, page] = await Promise.all([
23
- api.catalog.listCategories(),
24
- api.catalog.searchProducts({ ...data, page: data.page ?? 1, size: 24 }),
25
- ]);
26
- return { categories, page };
27
- });
9
+ import { parseProductsSearch, productsSearchParams, SORT_LABELS } from "../lib/products-search";
28
10
 
29
11
  export const Route = createFileRoute("/products/")({
30
- validateSearch: productsSearch,
12
+ // 목록·검색 조건은 URL에 둔다 — 읽는 규칙은 nuqs 파서 하나다(`lib/products-search.ts`)
13
+ validateSearch: parseProductsSearch,
31
14
  loaderDeps: ({ search }) => search,
32
- loader: ({ deps }) => searchProducts({ data: deps }),
15
+ // 서버 렌더에서 미리 받아 쿼리 캐시에 넣는다. 화면은 같은 쿼리를 캐시에서 읽는다
16
+ loader: ({ context, deps }) =>
17
+ Promise.all([
18
+ context.queryClient.ensureQueryData(categoriesQuery()),
19
+ context.queryClient.ensureQueryData(productsQuery(deps)),
20
+ ]),
33
21
  head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "전체 상품") }] }),
34
22
  component: Products,
35
23
  });
36
24
 
37
25
  function Products() {
38
- const { categories, page } = Route.useLoaderData();
39
26
  const applied = Route.useSearch();
40
- const navigate = useNavigate();
27
+ const { data: categories } = useSuspenseQuery(categoriesQuery());
28
+ const { data: page } = useSuspenseQuery(productsQuery(applied));
29
+ // 검색어·정렬을 바꾸면 URL만 고친다. 라우터가 바뀐 조건으로 loader를 다시 부른다
30
+ const [, setSearch] = useQueryStates(productsSearchParams, { history: "push" });
41
31
  useTrack(
42
32
  applied.keyword
43
33
  ? { name: "search", query: applied.keyword, resultCount: page.totalElements }
@@ -61,14 +51,12 @@ function Products() {
61
51
  {/* 하이드레이션 전에는 GET 폼으로 그대로 제출되고, 뒤에는 화면을 새로 그리지 않고 이동한다 */}
62
52
  <form
63
53
  action="/products"
64
- className="flex gap-2"
54
+ className="flex flex-wrap gap-2"
65
55
  onSubmit={(event) => {
66
56
  event.preventDefault();
67
57
  const keyword = String(new FormData(event.currentTarget).get("keyword") ?? "").trim();
68
- void navigate({
69
- to: "/products",
70
- search: { keyword: keyword || undefined, categoryId: applied.categoryId },
71
- });
58
+ // 새 검색은 1페이지부터 — 카테고리·정렬은 그대로 둔다
59
+ void setSearch({ keyword: keyword || null, page: null });
72
60
  }}
73
61
  >
74
62
  <input
@@ -77,7 +65,7 @@ function Products() {
77
65
  aria-label="상품 검색"
78
66
  defaultValue={applied.keyword ?? ""}
79
67
  placeholder="상품 검색"
80
- className="h-11 flex-1 rounded-md border border-line px-3"
68
+ className="h-11 min-w-0 flex-1 rounded-md border border-line px-3"
81
69
  />
82
70
  {applied.categoryId ? (
83
71
  <input type="hidden" name="categoryId" value={applied.categoryId} />
@@ -85,6 +73,22 @@ function Products() {
85
73
  <button type="submit" className="h-11 rounded-md bg-ink px-5 font-semibold text-white">
86
74
  검색
87
75
  </button>
76
+ <select
77
+ name="sort"
78
+ aria-label="정렬"
79
+ value={applied.sort ?? "recommend"}
80
+ onChange={(event) => {
81
+ const sort = productSortSchema.parse(event.target.value);
82
+ void setSearch({ sort, page: null });
83
+ }}
84
+ className="h-11 rounded-md border border-line px-3 text-sm"
85
+ >
86
+ {productSortSchema.options.map((sort) => (
87
+ <option key={sort} value={sort}>
88
+ {SORT_LABELS[sort]}
89
+ </option>
90
+ ))}
91
+ </select>
88
92
  </form>
89
93
 
90
94
  <div className="flex flex-wrap gap-2">
@@ -118,27 +122,21 @@ function Products() {
118
122
  </div>
119
123
  )}
120
124
 
121
- <Pagination page={page.page} totalPages={page.totalPages} search={applied} />
125
+ <Pagination page={page.page} totalPages={page.totalPages} />
122
126
  </div>
123
127
  );
124
128
  }
125
129
 
126
- function Pagination({
127
- page,
128
- totalPages,
129
- search,
130
- }: {
131
- page: number;
132
- totalPages: number;
133
- search: ProductsSearch;
134
- }) {
130
+ /** 페이지 이동은 링크다 — 자바스크립트 없이도 넘어가고, 다른 조건은 그대로 둔다 */
131
+ function Pagination({ page, totalPages }: { page: number; totalPages: number }) {
135
132
  if (totalPages <= 1) return null;
133
+ const pageOf = (next: number) => (next > 1 ? next : undefined);
136
134
  return (
137
135
  <nav className="flex items-center justify-center gap-3 text-sm">
138
136
  {page > 1 ? (
139
137
  <Link
140
138
  to="/products"
141
- search={{ ...search, page: page - 1 }}
139
+ search={(prev) => ({ ...prev, page: pageOf(page - 1) })}
142
140
  className="rounded-md border border-line px-3 py-1.5"
143
141
  >
144
142
  이전
@@ -150,7 +148,7 @@ function Pagination({
150
148
  {page < totalPages ? (
151
149
  <Link
152
150
  to="/products"
153
- search={{ ...search, page: page + 1 }}
151
+ search={(prev) => ({ ...prev, page: pageOf(page + 1) })}
154
152
  className="rounded-md border border-line px-3 py-1.5"
155
153
  >
156
154
  다음