@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.
Files changed (40) hide show
  1. package/dist/index.mjs +6 -4
  2. package/package.json +3 -3
  3. package/template/.template-meta.json +2 -2
  4. package/template/README.md +32 -2
  5. package/template/package.json +6 -0
  6. package/template/src/components/fulfillment-codes.tsx +110 -0
  7. package/template/src/components/fulfillment-downloads.tsx +88 -0
  8. package/template/src/components/text-field.tsx +71 -0
  9. package/template/src/lib/catalog-queries.ts +47 -0
  10. package/template/src/lib/code-reveal.test.ts +30 -0
  11. package/template/src/lib/code-reveal.ts +32 -0
  12. package/template/src/lib/coupon-code.test.ts +19 -0
  13. package/template/src/lib/coupon-code.ts +60 -0
  14. package/template/src/lib/download.test.ts +41 -0
  15. package/template/src/lib/download.ts +41 -0
  16. package/template/src/lib/form-schemas.test.ts +144 -0
  17. package/template/src/lib/form-schemas.ts +131 -0
  18. package/template/src/lib/guest-order.test.ts +18 -0
  19. package/template/src/lib/guest-order.ts +13 -0
  20. package/template/src/lib/order-status.test.ts +29 -0
  21. package/template/src/lib/order-status.ts +28 -2
  22. package/template/src/lib/page-title.test.ts +19 -0
  23. package/template/src/lib/page-title.ts +23 -0
  24. package/template/src/lib/products-search.test.ts +25 -0
  25. package/template/src/lib/products-search.ts +49 -0
  26. package/template/src/router.tsx +26 -1
  27. package/template/src/routes/__root.tsx +13 -6
  28. package/template/src/routes/account.tsx +2 -1
  29. package/template/src/routes/auth.callback.ts +13 -9
  30. package/template/src/routes/cart.tsx +13 -6
  31. package/template/src/routes/checkout.complete.tsx +2 -1
  32. package/template/src/routes/checkout.index.tsx +441 -147
  33. package/template/src/routes/checkout.return.tsx +7 -1
  34. package/template/src/routes/guest-order.tsx +174 -72
  35. package/template/src/routes/login.tsx +2 -1
  36. package/template/src/routes/orders.$orderId.tsx +98 -3
  37. package/template/src/routes/orders.index.tsx +2 -1
  38. package/template/src/routes/products.$productId.tsx +4 -1
  39. package/template/src/routes/products.index.tsx +46 -47
  40. 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: () => ({ meta: [{ title: "결제 확인" }, { name: "referrer", content: "no-referrer" }] }),
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 { orderItemStatusLabel } from "../lib/order-status";
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.replace(/[^0-9]/g, "");
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: () => ({ meta: [{ title: "비회원 주문 조회" }] }),
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 [pending, setPending] = useState(false);
131
+ const form = useForm<GuestOrderLookupInput, unknown, GuestOrderLookup>({
132
+ resolver: zodResolver(guestOrderLookupSchema),
133
+ });
134
+ const { errors, isSubmitting } = form.formState;
54
135
 
55
- const submit = (event: React.FormEvent<HTMLFormElement>) => {
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
- 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
- };
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
- <Field label="주문번호" name="orderId" defaultValue={orderId} placeholder="ord_" />
89
- <Field label="연락처" name="phone" type="tel" placeholder="01012345678" />
90
- <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
+ />
91
184
  <SubmitButton
92
- disabled={pending}
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="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}개
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
- </span>
127
- <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}
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 { orderItemStatusLabel } from "../lib/order-status";
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 }) => ({ meta: [{ title: `주문 ${params.orderId}` }] }),
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 }) => ({ meta: [{ title: loaderData ? loaderData.name : "상품" }] }),
56
+ head: ({ matches, loaderData }) => ({
57
+ meta: [{ title: pageTitle(matches, loaderData ? loaderData.name : "상품") }],
58
+ }),
56
59
  component: ProductDetail,
57
60
  });
58
61