@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.
- package/dist/index.mjs +6 -4
- package/package.json +3 -3
- package/template/.template-meta.json +2 -2
- package/template/README.md +27 -0
- package/template/package.json +6 -0
- package/template/src/components/fulfillment-codes.tsx +110 -0
- package/template/src/components/fulfillment-downloads.tsx +88 -0
- package/template/src/components/text-field.tsx +71 -0
- package/template/src/lib/catalog-queries.ts +47 -0
- package/template/src/lib/code-reveal.test.ts +30 -0
- package/template/src/lib/code-reveal.ts +32 -0
- package/template/src/lib/coupon-code.test.ts +19 -0
- package/template/src/lib/coupon-code.ts +60 -0
- package/template/src/lib/download.test.ts +41 -0
- package/template/src/lib/download.ts +41 -0
- package/template/src/lib/form-schemas.test.ts +144 -0
- package/template/src/lib/form-schemas.ts +131 -0
- package/template/src/lib/order-status.test.ts +29 -0
- package/template/src/lib/order-status.ts +28 -2
- package/template/src/lib/products-search.test.ts +25 -0
- package/template/src/lib/products-search.ts +49 -0
- package/template/src/router.tsx +26 -1
- package/template/src/routes/__root.tsx +9 -4
- package/template/src/routes/auth.callback.ts +13 -9
- package/template/src/routes/cart.tsx +11 -5
- package/template/src/routes/checkout.index.tsx +439 -146
- package/template/src/routes/guest-order.tsx +161 -71
- package/template/src/routes/orders.$orderId.tsx +94 -2
- package/template/src/routes/products.index.tsx +44 -46
- package/template/src/routes/signup.tsx +59 -89
|
@@ -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
|
|
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
|
|
131
|
+
const form = useForm<GuestOrderLookupInput, unknown, GuestOrderLookup>({
|
|
132
|
+
resolver: zodResolver(guestOrderLookupSchema),
|
|
133
|
+
});
|
|
134
|
+
const { errors, isSubmitting } = form.formState;
|
|
66
135
|
|
|
67
|
-
const submit = (
|
|
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
|
-
|
|
73
|
-
data:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
101
|
-
<
|
|
102
|
-
|
|
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={
|
|
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="
|
|
125
|
-
<
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
<span
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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 {
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
12
|
+
// 목록·검색 조건은 URL에 둔다 — 읽는 규칙은 nuqs 파서 하나다(`lib/products-search.ts`)
|
|
13
|
+
validateSearch: parseProductsSearch,
|
|
31
14
|
loaderDeps: ({ search }) => search,
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
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}
|
|
125
|
+
<Pagination page={page.page} totalPages={page.totalPages} />
|
|
122
126
|
</div>
|
|
123
127
|
);
|
|
124
128
|
}
|
|
125
129
|
|
|
126
|
-
|
|
127
|
-
|
|
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={{ ...
|
|
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={{ ...
|
|
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
|
다음
|