@sayren/mcp 0.6.0 → 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 +32 -2
- 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/guest-order.test.ts +18 -0
- package/template/src/lib/guest-order.ts +13 -0
- package/template/src/lib/order-status.test.ts +29 -0
- package/template/src/lib/order-status.ts +28 -2
- package/template/src/lib/page-title.test.ts +19 -0
- package/template/src/lib/page-title.ts +23 -0
- 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 +13 -6
- package/template/src/routes/account.tsx +2 -1
- package/template/src/routes/auth.callback.ts +13 -9
- package/template/src/routes/cart.tsx +13 -6
- package/template/src/routes/checkout.complete.tsx +2 -1
- package/template/src/routes/checkout.index.tsx +441 -147
- package/template/src/routes/checkout.return.tsx +7 -1
- package/template/src/routes/guest-order.tsx +174 -72
- package/template/src/routes/login.tsx +2 -1
- package/template/src/routes/orders.$orderId.tsx +98 -3
- package/template/src/routes/orders.index.tsx +2 -1
- package/template/src/routes/products.$productId.tsx +4 -1
- package/template/src/routes/products.index.tsx +46 -47
- package/template/src/routes/signup.tsx +61 -90
|
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from "react";
|
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import type { PublicConfig } from "../lib/config";
|
|
8
8
|
import { API_BASE_URL, resolveStoreCode } from "../lib/config.server";
|
|
9
|
+
import { pageTitle } from "../lib/page-title";
|
|
9
10
|
import { forgetPaymentOptions, recallPaymentOptions } from "../lib/payment-options";
|
|
10
11
|
import { paymentsFor } from "../lib/payments";
|
|
11
12
|
import { safeRedirect } from "../lib/safe-redirect";
|
|
@@ -50,7 +51,12 @@ export const Route = createFileRoute("/checkout/return")({
|
|
|
50
51
|
validateSearch: returnSearch,
|
|
51
52
|
loader: () => getConfig(),
|
|
52
53
|
// 복귀 주소의 결제 id가 외부 리소스 요청의 Referer로 새지 않게 한다
|
|
53
|
-
head: () => ({
|
|
54
|
+
head: ({ matches }) => ({
|
|
55
|
+
meta: [
|
|
56
|
+
{ title: pageTitle(matches, "결제 확인") },
|
|
57
|
+
{ name: "referrer", content: "no-referrer" },
|
|
58
|
+
],
|
|
59
|
+
}),
|
|
54
60
|
component: PaymentReturn,
|
|
55
61
|
});
|
|
56
62
|
|
|
@@ -1,13 +1,27 @@
|
|
|
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
|
-
import {
|
|
22
|
+
import { guestLookupLockedMessage } from "../lib/guest-order";
|
|
23
|
+
import { orderItemStatusLabel, withdrawalRestrictedLabel } from "../lib/order-status";
|
|
24
|
+
import { pageTitle } from "../lib/page-title";
|
|
11
25
|
|
|
12
26
|
const guestOrderSearch = z.object({
|
|
13
27
|
/** 주문 완료 화면이 주문번호를 채워 보낸다 */
|
|
@@ -22,7 +36,7 @@ const lookupGuestOrder = createServerFn({ method: "POST" })
|
|
|
22
36
|
.validator(z.object({ orderId: z.string(), phone: z.string(), orderPassword: z.string() }))
|
|
23
37
|
.handler(async ({ data }) => {
|
|
24
38
|
const orderId = data.orderId.trim();
|
|
25
|
-
const phone = data.phone
|
|
39
|
+
const phone = normalizePhone(data.phone);
|
|
26
40
|
if (!orderId || !phone || !data.orderPassword) {
|
|
27
41
|
return { order: null, error: "주문번호·연락처·비밀번호를 모두 입력해 주십시오" };
|
|
28
42
|
}
|
|
@@ -36,44 +50,102 @@ const lookupGuestOrder = createServerFn({ method: "POST" })
|
|
|
36
50
|
if (error instanceof ApiError && (error.status === 401 || error.status === 404)) {
|
|
37
51
|
return { order: null, error: "주문을 찾지 못했습니다. 입력한 내용을 확인해 주십시오" };
|
|
38
52
|
}
|
|
53
|
+
// 주문번호마다 시도 한도가 있다. 여러 번 틀리면 한동안 맞는 비밀번호로도 조회되지 않는다
|
|
54
|
+
if (error instanceof ApiError && error.status === 429) {
|
|
55
|
+
return { order: null, error: guestLookupLockedMessage(error.details?.retryAfterSeconds) };
|
|
56
|
+
}
|
|
39
57
|
return { order: null, error: "조회하지 못했습니다. 잠시 후 다시 시도해 주십시오" };
|
|
40
58
|
}
|
|
41
59
|
});
|
|
42
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
|
+
|
|
43
113
|
export const Route = createFileRoute("/guest-order")({
|
|
44
114
|
validateSearch: guestOrderSearch,
|
|
45
|
-
head: () => ({
|
|
115
|
+
head: ({ matches }) => ({
|
|
116
|
+
// 주소에 주문번호(`?orderId=`)가 실린다. 외부 링크로 나갈 때 리퍼러로 흘리지 않는다
|
|
117
|
+
meta: [
|
|
118
|
+
{ title: pageTitle(matches, "비회원 주문 조회") },
|
|
119
|
+
{ name: "referrer", content: "no-referrer" },
|
|
120
|
+
],
|
|
121
|
+
}),
|
|
46
122
|
component: GuestOrder,
|
|
47
123
|
});
|
|
48
124
|
|
|
49
125
|
function GuestOrder() {
|
|
50
126
|
const { orderId } = Route.useSearch();
|
|
51
127
|
const [order, setOrder] = useState<MyOrder | null>(null);
|
|
128
|
+
// 코드 열람에 다시 쓰는 조회 값 — 이 컴포넌트 상태에만 둔다(주소·스토리지에 남기지 않는다)
|
|
129
|
+
const [lookup, setLookup] = useState<GuestOrderLookup | null>(null);
|
|
52
130
|
const [error, setError] = useState<string | null>(null);
|
|
53
|
-
const
|
|
131
|
+
const form = useForm<GuestOrderLookupInput, unknown, GuestOrderLookup>({
|
|
132
|
+
resolver: zodResolver(guestOrderLookupSchema),
|
|
133
|
+
});
|
|
134
|
+
const { errors, isSubmitting } = form.formState;
|
|
54
135
|
|
|
55
|
-
const submit = (
|
|
56
|
-
event.preventDefault();
|
|
57
|
-
const form = new FormData(event.currentTarget);
|
|
58
|
-
setPending(true);
|
|
136
|
+
const submit = form.handleSubmit(async (values) => {
|
|
59
137
|
setError(null);
|
|
60
|
-
|
|
61
|
-
data:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
.catch(() => {
|
|
72
|
-
setOrder(null);
|
|
73
|
-
setError("조회하지 못했습니다. 잠시 후 다시 시도해 주십시오");
|
|
74
|
-
})
|
|
75
|
-
.finally(() => setPending(false));
|
|
76
|
-
};
|
|
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
|
+
});
|
|
77
149
|
|
|
78
150
|
return (
|
|
79
151
|
<div className="mx-auto max-w-lg space-y-6 py-10">
|
|
@@ -85,11 +157,32 @@ function GuestOrder() {
|
|
|
85
157
|
</div>
|
|
86
158
|
|
|
87
159
|
<form onSubmit={submit} className="space-y-3">
|
|
88
|
-
|
|
89
|
-
<
|
|
90
|
-
|
|
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
|
+
/>
|
|
91
184
|
<SubmitButton
|
|
92
|
-
disabled={
|
|
185
|
+
disabled={isSubmitting}
|
|
93
186
|
className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
|
|
94
187
|
>
|
|
95
188
|
조회하기
|
|
@@ -109,22 +202,59 @@ function GuestOrder() {
|
|
|
109
202
|
</div>
|
|
110
203
|
<ul className="divide-y divide-line">
|
|
111
204
|
{order.items.map((item) => (
|
|
112
|
-
<li key={item.orderItemId} className="
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
<span
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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}
|
|
125
224
|
</span>
|
|
126
|
-
|
|
127
|
-
|
|
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}
|
|
128
258
|
</li>
|
|
129
259
|
))}
|
|
130
260
|
</ul>
|
|
@@ -141,31 +271,3 @@ function GuestOrder() {
|
|
|
141
271
|
</div>
|
|
142
272
|
);
|
|
143
273
|
}
|
|
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
|
-
}
|
|
@@ -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
|
|
|
@@ -11,11 +11,20 @@ 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";
|
|
27
|
+
import { pageTitle } from "../lib/page-title";
|
|
19
28
|
import { readToken } from "../lib/session.server";
|
|
20
29
|
|
|
21
30
|
const getOrder = createServerFn({ method: "GET" })
|
|
@@ -33,6 +42,42 @@ const getOrder = createServerFn({ method: "GET" })
|
|
|
33
42
|
}
|
|
34
43
|
});
|
|
35
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
|
+
|
|
36
81
|
/**
|
|
37
82
|
* 취소·반품 접수 — 수량을 실으면 그 수량만 접수된다(생략하면 남은 수량 전체).
|
|
38
83
|
* 상한은 주문 상품의 `activeQuantity`이고 넘기면 서버가 400 `INVALID_QUANTITY`로 거절한다.
|
|
@@ -73,6 +118,8 @@ function claimErrorMessage(error: unknown): string {
|
|
|
73
118
|
return "지금은 신청할 수 없는 상태입니다. 주문 상태를 확인해 주십시오.";
|
|
74
119
|
case "PERIOD_EXPIRED":
|
|
75
120
|
return "반품·교환 가능 기간이 지났습니다.";
|
|
121
|
+
case "WITHDRAWAL_RESTRICTED":
|
|
122
|
+
return WITHDRAWAL_RESTRICTED_CLAIM_MESSAGE;
|
|
76
123
|
default:
|
|
77
124
|
return error.message || "접수하지 못했습니다. 잠시 후 다시 시도해 주십시오.";
|
|
78
125
|
}
|
|
@@ -80,7 +127,9 @@ function claimErrorMessage(error: unknown): string {
|
|
|
80
127
|
|
|
81
128
|
export const Route = createFileRoute("/orders/$orderId")({
|
|
82
129
|
loader: ({ params }) => getOrder({ data: { orderId: params.orderId } }),
|
|
83
|
-
head: ({ params }) => ({
|
|
130
|
+
head: ({ matches, params }) => ({
|
|
131
|
+
meta: [{ title: pageTitle(matches, `주문 ${params.orderId}`) }],
|
|
132
|
+
}),
|
|
84
133
|
component: OrderDetail,
|
|
85
134
|
});
|
|
86
135
|
|
|
@@ -181,15 +230,30 @@ function OrderDetail() {
|
|
|
181
230
|
<span className="text-muted text-xs">{item.optionName}</span>
|
|
182
231
|
) : null}
|
|
183
232
|
<span className="text-muted text-xs">
|
|
184
|
-
{orderItemStatusLabel(item.status)} · 수량 {item.quantity}개
|
|
233
|
+
{orderItemStatusLabel(item.status, item.fulfillmentType)} · 수량 {item.quantity}개
|
|
185
234
|
{item.canceledQuantity > 0
|
|
186
235
|
? ` · 취소·반품 ${item.canceledQuantity}개 · 남은 수량 ${item.activeQuantity}개`
|
|
187
236
|
: null}
|
|
188
237
|
</span>
|
|
238
|
+
{withdrawalRestrictedLabel(item) ? (
|
|
239
|
+
<span className="text-muted text-xs">{withdrawalRestrictedLabel(item)}</span>
|
|
240
|
+
) : null}
|
|
189
241
|
</span>
|
|
190
242
|
<span className="text-sm">{formatPrice(item.totalPrice)}</span>
|
|
191
243
|
</div>
|
|
192
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
|
+
|
|
193
257
|
{claimableTypes(item.status).length > 0 &&
|
|
194
258
|
!item.claimStatus &&
|
|
195
259
|
item.activeQuantity > 0 ? (
|
|
@@ -244,6 +308,37 @@ function OrderDetail() {
|
|
|
244
308
|
);
|
|
245
309
|
}
|
|
246
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
|
+
|
|
247
342
|
/**
|
|
248
343
|
* 취소·반품 접수 폼. 수량 기본값은 남은 수량 전체이고 입력값은 `activeQuantity`까지다.
|
|
249
344
|
* 서버 렌더한 마크업에 값이 그대로 남아야 하므로 프리필은 `defaultValue`로 준다.
|
|
@@ -2,6 +2,7 @@ import { createFileRoute, Link, redirect } from "@tanstack/react-router";
|
|
|
2
2
|
import { createServerFn } from "@tanstack/react-start";
|
|
3
3
|
import { apiFor } from "../lib/api.server";
|
|
4
4
|
import { formatDateTime, formatPrice } from "../lib/format";
|
|
5
|
+
import { pageTitle } from "../lib/page-title";
|
|
5
6
|
import { readToken } from "../lib/session.server";
|
|
6
7
|
|
|
7
8
|
const getOrders = createServerFn({ method: "GET" }).handler(async () => {
|
|
@@ -12,7 +13,7 @@ const getOrders = createServerFn({ method: "GET" }).handler(async () => {
|
|
|
12
13
|
|
|
13
14
|
export const Route = createFileRoute("/orders/")({
|
|
14
15
|
loader: () => getOrders(),
|
|
15
|
-
head: () => ({ meta: [{ title: "주문 내역" }] }),
|
|
16
|
+
head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "주문 내역") }] }),
|
|
16
17
|
component: Orders,
|
|
17
18
|
});
|
|
18
19
|
|
|
@@ -10,6 +10,7 @@ import { useTrack } from "../lib/analytics";
|
|
|
10
10
|
import { apiFor } from "../lib/api.server";
|
|
11
11
|
import { readCartToken, writeCartToken } from "../lib/cart-session.server";
|
|
12
12
|
import { formatPrice } from "../lib/format";
|
|
13
|
+
import { pageTitle } from "../lib/page-title";
|
|
13
14
|
import { readToken } from "../lib/session.server";
|
|
14
15
|
|
|
15
16
|
const getProduct = createServerFn({ method: "GET" })
|
|
@@ -52,7 +53,9 @@ const addToCart = createServerFn({ method: "POST" })
|
|
|
52
53
|
|
|
53
54
|
export const Route = createFileRoute("/products/$productId")({
|
|
54
55
|
loader: ({ params }) => getProduct({ data: { productId: params.productId } }),
|
|
55
|
-
head: ({ loaderData }) => ({
|
|
56
|
+
head: ({ matches, loaderData }) => ({
|
|
57
|
+
meta: [{ title: pageTitle(matches, loaderData ? loaderData.name : "상품") }],
|
|
58
|
+
}),
|
|
56
59
|
component: ProductDetail,
|
|
57
60
|
});
|
|
58
61
|
|