@sayren/mcp 0.6.0 → 0.6.1
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/package.json +3 -3
- package/template/.template-meta.json +2 -2
- package/template/README.md +5 -2
- package/template/src/lib/guest-order.test.ts +18 -0
- package/template/src/lib/guest-order.ts +13 -0
- package/template/src/lib/page-title.test.ts +19 -0
- package/template/src/lib/page-title.ts +23 -0
- package/template/src/routes/__root.tsx +4 -2
- package/template/src/routes/account.tsx +2 -1
- package/template/src/routes/cart.tsx +2 -1
- package/template/src/routes/checkout.complete.tsx +2 -1
- package/template/src/routes/checkout.index.tsx +2 -1
- package/template/src/routes/checkout.return.tsx +7 -1
- package/template/src/routes/guest-order.tsx +13 -1
- package/template/src/routes/login.tsx +2 -1
- package/template/src/routes/orders.$orderId.tsx +4 -1
- 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 +2 -1
- package/template/src/routes/signup.tsx +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sayren/mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"sayren-mcp": "./dist/index.mjs"
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@modelcontextprotocol/sdk": "^1.22.0",
|
|
14
14
|
"zod": "^4.6.5",
|
|
15
|
-
"@sayren/store-sdk": "^0.
|
|
16
|
-
"@sayren/storefront-sdk": "^0.
|
|
15
|
+
"@sayren/store-sdk": "^0.16.0",
|
|
16
|
+
"@sayren/storefront-sdk": "^0.13.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@biomejs/biome": "^2.5.14",
|
package/template/README.md
CHANGED
|
@@ -50,7 +50,8 @@ SAYREN_STORE_CODE=내_스토어_코드
|
|
|
50
50
|
화면 데이터는 라우트 `loader`가 서버 함수(`createServerFn`)를 불러 받습니다. 첫 요청은 서버에서 그리고, 이후 이동은
|
|
51
51
|
브라우저가 같은 서버 함수를 호출합니다. 담기·결제 요청·로그인 같은 쓰기도 서버 함수라서 토큰이 브라우저 JS에 나가지 않습니다.
|
|
52
52
|
|
|
53
|
-
헤더와 브라우저 탭 제목은 셀러 콘솔 설정 › 스토어 정보의 스토어 이름·로고(`GET /store`)를 씁니다.
|
|
53
|
+
헤더와 브라우저 탭 제목은 셀러 콘솔 설정 › 스토어 정보의 스토어 이름·로고(`GET /store`)를 씁니다. 탭 제목은
|
|
54
|
+
`{화면} | {스토어 이름}`(예: `회원가입 | 나의첫번째몰`)이고 홈은 스토어 이름만 씁니다. 형식은 `src/lib/page-title.ts` 한 곳에서 바꿉니다.
|
|
54
55
|
|
|
55
56
|
## 방문 분석
|
|
56
57
|
|
|
@@ -111,7 +112,9 @@ EU 등 분석 쿠키에 사전 동의가 필요한 지역의 구매자를 받는
|
|
|
111
112
|
매번 다시 계산해야 하기 때문입니다.
|
|
112
113
|
- 비회원 장바구니는 서버가 발급한 토큰으로만 찾습니다. 쿠키를 지우면 담은 상품이 사라집니다.
|
|
113
114
|
- 비회원 주문은 주문번호와 주문서에 적은 연락처·주문 조회 비밀번호로 찾습니다(`/guest-order`). 주문 완료 화면이 주문번호를
|
|
114
|
-
채워 보냅니다.
|
|
115
|
+
채워 보냅니다. 주문번호마다 10분에 5번까지 확인하고 5번째가 틀리면 30분 동안(하루 15번을 넘으면 그날 끝까지) `429 TOO_MANY_REQUESTS`라서,
|
|
116
|
+
화면은 남은 시간(`details.retryAfterSeconds`)을 분 단위로 안내합니다(`src/lib/guest-order.ts`). 주문이 없는 것과
|
|
117
|
+
연락처·비밀번호가 틀린 것은 같은 `404`입니다.
|
|
115
118
|
- 배송비는 배송 묶음마다 한 번 붙습니다. 같은 출고지 + 같은 배송 정책인 상품이 한 묶음이고, 제주·도서산간 추가
|
|
116
119
|
배송비는 묶음마다 더하며 무료배송이어도 부과합니다. 주문서를 만들 때는 배송지를 몰라 주문서의 `delivery.zipCode`가
|
|
117
120
|
null이고 추가 배송비가 0입니다. 주문서(`/checkout`)는 우편번호 입력을 마칠 때(blur) `quoteDelivery` 서버 함수로
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { guestLookupLockedMessage } from "./guest-order";
|
|
3
|
+
|
|
4
|
+
describe("guestLookupLockedMessage", () => {
|
|
5
|
+
it("남은 시간을 분 단위로 올려 알린다", () => {
|
|
6
|
+
expect(guestLookupLockedMessage(1800)).toBe(
|
|
7
|
+
"조회 시도가 너무 많습니다. 30분 뒤에 다시 시도해 주십시오",
|
|
8
|
+
);
|
|
9
|
+
expect(guestLookupLockedMessage(61)).toContain("2분 뒤");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("남은 시간을 모르면 잠시 후로 안내한다", () => {
|
|
13
|
+
expect(guestLookupLockedMessage(undefined)).toBe(
|
|
14
|
+
"조회 시도가 너무 많습니다. 잠시 후 다시 시도해 주십시오",
|
|
15
|
+
);
|
|
16
|
+
expect(guestLookupLockedMessage("30")).toContain("잠시 후");
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 비회원 주문 조회 잠금 안내 — API는 주문번호마다 시도 한도를 두고, 넘으면 429 `TOO_MANY_REQUESTS`와
|
|
3
|
+
* 남은 시간(`details.retryAfterSeconds`)을 준다. 남은 시간은 분 단위로 올려 알린다.
|
|
4
|
+
*/
|
|
5
|
+
export function guestLookupLockedMessage(retryAfterSeconds: unknown): string {
|
|
6
|
+
const minutes =
|
|
7
|
+
typeof retryAfterSeconds === "number" && retryAfterSeconds > 0
|
|
8
|
+
? Math.ceil(retryAfterSeconds / 60)
|
|
9
|
+
: null;
|
|
10
|
+
return minutes
|
|
11
|
+
? `조회 시도가 너무 많습니다. ${minutes}분 뒤에 다시 시도해 주십시오`
|
|
12
|
+
: "조회 시도가 너무 많습니다. 잠시 후 다시 시도해 주십시오";
|
|
13
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { pageTitle } from "./page-title";
|
|
3
|
+
|
|
4
|
+
const root = { loaderData: { store: { name: "나의첫번째몰", logoUrl: null } } };
|
|
5
|
+
|
|
6
|
+
describe("pageTitle", () => {
|
|
7
|
+
it("화면 이름 뒤에 스토어 이름을 붙인다", () => {
|
|
8
|
+
expect(pageTitle([root, { loaderData: {} }], "회원가입")).toBe("회원가입 | 나의첫번째몰");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("화면 이름이 없으면 스토어 이름만 쓴다", () => {
|
|
12
|
+
expect(pageTitle([root])).toBe("나의첫번째몰");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("스토어 이름을 읽지 못하면 기본 이름을 쓴다", () => {
|
|
16
|
+
expect(pageTitle([{ loaderData: { store: null } }], "장바구니")).toBe("장바구니 | 스토어");
|
|
17
|
+
expect(pageTitle([], "장바구니")).toBe("장바구니 | 스토어");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** 스토어 이름을 읽지 못했을 때 탭 제목 */
|
|
2
|
+
export const FALLBACK_STORE_NAME = "스토어";
|
|
3
|
+
|
|
4
|
+
/** 루트 라우트 loader가 내려 주는 스토어 이름 — `__root.tsx`의 `RootData.store` */
|
|
5
|
+
function storeNameOf(matches: ReadonlyArray<{ loaderData?: unknown }>): string | null {
|
|
6
|
+
const root = matches[0]?.loaderData as { store?: { name?: unknown } | null } | undefined;
|
|
7
|
+
const name = root?.store?.name;
|
|
8
|
+
return typeof name === "string" && name.trim() ? name : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 탭 제목 — `{화면} | {스토어 이름}`. 홈처럼 화면 이름이 없으면 스토어 이름만 쓴다.
|
|
13
|
+
* 라우트 `head`의 `matches`를 그대로 넘긴다(루트 loader가 스토어 이름을 싣는다).
|
|
14
|
+
*
|
|
15
|
+
* 확장 지점 — 구분자나 순서를 바꾸려면 여기만 고친다.
|
|
16
|
+
*/
|
|
17
|
+
export function pageTitle(
|
|
18
|
+
matches: ReadonlyArray<{ loaderData?: unknown }>,
|
|
19
|
+
screen?: string | null,
|
|
20
|
+
): string {
|
|
21
|
+
const store = storeNameOf(matches) ?? FALLBACK_STORE_NAME;
|
|
22
|
+
return screen ? `${screen} | ${store}` : store;
|
|
23
|
+
}
|
|
@@ -13,6 +13,7 @@ import { SiteHeader, type StoreBrand } from "../components/site-header";
|
|
|
13
13
|
import { type AnalyticsConfig, startAnalytics } from "../lib/analytics";
|
|
14
14
|
import { apiFor } from "../lib/api.server";
|
|
15
15
|
import { ANALYTICS_DEBUG, API_BASE_URL, resolveStoreCode } from "../lib/config.server";
|
|
16
|
+
import { FALLBACK_STORE_NAME } from "../lib/page-title";
|
|
16
17
|
import appCss from "../styles.css?url";
|
|
17
18
|
|
|
18
19
|
/** 방문 분석 설정 — 서버 값(`process.env`)이라 서버 함수로 내려준다 */
|
|
@@ -47,8 +48,9 @@ export const Route = createRootRoute({
|
|
|
47
48
|
meta: [
|
|
48
49
|
{ charSet: "utf-8" },
|
|
49
50
|
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
|
50
|
-
// 화면마다
|
|
51
|
-
|
|
51
|
+
// 화면마다 `{화면} | {스토어 이름}`으로 덮어쓴다(`lib/page-title.ts`). 여기 값은 홈처럼
|
|
52
|
+
// 화면 이름이 없는 곳의 제목이다
|
|
53
|
+
{ title: loaderData?.store?.name ?? FALLBACK_STORE_NAME },
|
|
52
54
|
],
|
|
53
55
|
links: [
|
|
54
56
|
{ rel: "stylesheet", href: appCss },
|
|
@@ -11,6 +11,7 @@ import { z } from "zod";
|
|
|
11
11
|
import { SubmitButton } from "../components/submit-button";
|
|
12
12
|
import { apiFor, authFor } from "../lib/api.server";
|
|
13
13
|
import { formatDateTime } from "../lib/format";
|
|
14
|
+
import { pageTitle } from "../lib/page-title";
|
|
14
15
|
import { readToken, signOut } from "../lib/session.server";
|
|
15
16
|
import { SOCIAL_BUTTONS } from "../lib/social-buttons";
|
|
16
17
|
import { writeSocialLogin } from "../lib/social-login.server";
|
|
@@ -181,7 +182,7 @@ function accountErrorMessage(error: unknown): string {
|
|
|
181
182
|
export const Route = createFileRoute("/account")({
|
|
182
183
|
validateSearch: accountSearch,
|
|
183
184
|
loader: () => getAccount(),
|
|
184
|
-
head: () => ({ meta: [{ title: "내 정보" }] }),
|
|
185
|
+
head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "내 정보") }] }),
|
|
185
186
|
component: Account,
|
|
186
187
|
});
|
|
187
188
|
|
|
@@ -7,6 +7,7 @@ import { SubmitButton } from "../components/submit-button";
|
|
|
7
7
|
import { apiFor } from "../lib/api.server";
|
|
8
8
|
import { readCartToken, writeCartToken } from "../lib/cart-session.server";
|
|
9
9
|
import { formatPrice } from "../lib/format";
|
|
10
|
+
import { pageTitle } from "../lib/page-title";
|
|
10
11
|
import { readToken } from "../lib/session.server";
|
|
11
12
|
|
|
12
13
|
const getCart = createServerFn({ method: "GET" }).handler(async () => {
|
|
@@ -54,7 +55,7 @@ const changeCart = createServerFn({ method: "POST" })
|
|
|
54
55
|
|
|
55
56
|
export const Route = createFileRoute("/cart")({
|
|
56
57
|
loader: () => getCart(),
|
|
57
|
-
head: () => ({ meta: [{ title: "장바구니" }] }),
|
|
58
|
+
head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "장바구니") }] }),
|
|
58
59
|
component: CartPage,
|
|
59
60
|
});
|
|
60
61
|
|
|
@@ -3,6 +3,7 @@ import { createServerFn } from "@tanstack/react-start";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { apiFor } from "../lib/api.server";
|
|
5
5
|
import { formatPrice } from "../lib/format";
|
|
6
|
+
import { pageTitle } from "../lib/page-title";
|
|
6
7
|
import { readToken } from "../lib/session.server";
|
|
7
8
|
|
|
8
9
|
const completeSearch = z.object({ orderId: z.string().optional().catch(undefined) });
|
|
@@ -23,7 +24,7 @@ export const Route = createFileRoute("/checkout/complete")({
|
|
|
23
24
|
validateSearch: completeSearch,
|
|
24
25
|
loaderDeps: ({ search }) => search,
|
|
25
26
|
loader: ({ deps }) => getCompletedOrder({ data: deps }),
|
|
26
|
-
head: () => ({ meta: [{ title: "주문 완료" }] }),
|
|
27
|
+
head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "주문 완료") }] }),
|
|
27
28
|
component: CheckoutComplete,
|
|
28
29
|
});
|
|
29
30
|
|
|
@@ -21,6 +21,7 @@ import { checkoutIntentFrom, checkoutSearch } from "../lib/checkout-intent";
|
|
|
21
21
|
import type { PublicConfig } from "../lib/config";
|
|
22
22
|
import { API_BASE_URL, resolveStoreCode } from "../lib/config.server";
|
|
23
23
|
import { formatPrice } from "../lib/format";
|
|
24
|
+
import { pageTitle } from "../lib/page-title";
|
|
24
25
|
import { forgetPaymentOptions, rememberPaymentOptions } from "../lib/payment-options";
|
|
25
26
|
import { PAYMENT_RETURN_PATH, paymentsFor } from "../lib/payments";
|
|
26
27
|
import { readToken } from "../lib/session.server";
|
|
@@ -151,7 +152,7 @@ export const Route = createFileRoute("/checkout/")({
|
|
|
151
152
|
validateSearch: checkoutSearch,
|
|
152
153
|
loaderDeps: ({ search }) => search,
|
|
153
154
|
loader: ({ deps, location }) => createCheckout({ data: { search: deps, back: location.href } }),
|
|
154
|
-
head: () => ({ meta: [{ title: "주문서" }] }),
|
|
155
|
+
head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "주문서") }] }),
|
|
155
156
|
component: Checkout,
|
|
156
157
|
});
|
|
157
158
|
|
|
@@ -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
|
|
|
@@ -7,7 +7,9 @@ import { ProductThumb } from "../components/product-thumb";
|
|
|
7
7
|
import { SubmitButton } from "../components/submit-button";
|
|
8
8
|
import { apiFor } from "../lib/api.server";
|
|
9
9
|
import { formatDateTime, formatPrice } from "../lib/format";
|
|
10
|
+
import { guestLookupLockedMessage } from "../lib/guest-order";
|
|
10
11
|
import { orderItemStatusLabel } from "../lib/order-status";
|
|
12
|
+
import { pageTitle } from "../lib/page-title";
|
|
11
13
|
|
|
12
14
|
const guestOrderSearch = z.object({
|
|
13
15
|
/** 주문 완료 화면이 주문번호를 채워 보낸다 */
|
|
@@ -36,13 +38,23 @@ const lookupGuestOrder = createServerFn({ method: "POST" })
|
|
|
36
38
|
if (error instanceof ApiError && (error.status === 401 || error.status === 404)) {
|
|
37
39
|
return { order: null, error: "주문을 찾지 못했습니다. 입력한 내용을 확인해 주십시오" };
|
|
38
40
|
}
|
|
41
|
+
// 주문번호마다 시도 한도가 있다. 여러 번 틀리면 한동안 맞는 비밀번호로도 조회되지 않는다
|
|
42
|
+
if (error instanceof ApiError && error.status === 429) {
|
|
43
|
+
return { order: null, error: guestLookupLockedMessage(error.details?.retryAfterSeconds) };
|
|
44
|
+
}
|
|
39
45
|
return { order: null, error: "조회하지 못했습니다. 잠시 후 다시 시도해 주십시오" };
|
|
40
46
|
}
|
|
41
47
|
});
|
|
42
48
|
|
|
43
49
|
export const Route = createFileRoute("/guest-order")({
|
|
44
50
|
validateSearch: guestOrderSearch,
|
|
45
|
-
head: () => ({
|
|
51
|
+
head: ({ matches }) => ({
|
|
52
|
+
// 주소에 주문번호(`?orderId=`)가 실린다. 외부 링크로 나갈 때 리퍼러로 흘리지 않는다
|
|
53
|
+
meta: [
|
|
54
|
+
{ title: pageTitle(matches, "비회원 주문 조회") },
|
|
55
|
+
{ name: "referrer", content: "no-referrer" },
|
|
56
|
+
],
|
|
57
|
+
}),
|
|
46
58
|
component: GuestOrder,
|
|
47
59
|
});
|
|
48
60
|
|
|
@@ -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
|
|
|
@@ -16,6 +16,7 @@ import { SubmitButton } from "../components/submit-button";
|
|
|
16
16
|
import { apiFor } from "../lib/api.server";
|
|
17
17
|
import { formatDateTime, formatPrice } from "../lib/format";
|
|
18
18
|
import { orderItemStatusLabel } from "../lib/order-status";
|
|
19
|
+
import { pageTitle } from "../lib/page-title";
|
|
19
20
|
import { readToken } from "../lib/session.server";
|
|
20
21
|
|
|
21
22
|
const getOrder = createServerFn({ method: "GET" })
|
|
@@ -80,7 +81,9 @@ function claimErrorMessage(error: unknown): string {
|
|
|
80
81
|
|
|
81
82
|
export const Route = createFileRoute("/orders/$orderId")({
|
|
82
83
|
loader: ({ params }) => getOrder({ data: { orderId: params.orderId } }),
|
|
83
|
-
head: ({ params }) => ({
|
|
84
|
+
head: ({ matches, params }) => ({
|
|
85
|
+
meta: [{ title: pageTitle(matches, `주문 ${params.orderId}`) }],
|
|
86
|
+
}),
|
|
84
87
|
component: OrderDetail,
|
|
85
88
|
});
|
|
86
89
|
|
|
@@ -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
|
|
|
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { ProductCard } from "../components/product-card";
|
|
5
5
|
import { useTrack } from "../lib/analytics";
|
|
6
6
|
import { apiFor } from "../lib/api.server";
|
|
7
|
+
import { pageTitle } from "../lib/page-title";
|
|
7
8
|
|
|
8
9
|
/** 목록·검색 조건 — URL에 둔다. 공유·뒤로가기가 그대로 동작한다 */
|
|
9
10
|
const productsSearch = z.object({
|
|
@@ -29,7 +30,7 @@ export const Route = createFileRoute("/products/")({
|
|
|
29
30
|
validateSearch: productsSearch,
|
|
30
31
|
loaderDeps: ({ search }) => search,
|
|
31
32
|
loader: ({ deps }) => searchProducts({ data: deps }),
|
|
32
|
-
head: () => ({ meta: [{ title: "전체 상품" }] }),
|
|
33
|
+
head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "전체 상품") }] }),
|
|
33
34
|
component: Products,
|
|
34
35
|
});
|
|
35
36
|
|
|
@@ -6,6 +6,7 @@ import { z } from "zod";
|
|
|
6
6
|
import { SubmitButton } from "../components/submit-button";
|
|
7
7
|
import { apiFor, authFor } from "../lib/api.server";
|
|
8
8
|
import { clearCartToken, readCartToken } from "../lib/cart-session.server";
|
|
9
|
+
import { pageTitle } from "../lib/page-title";
|
|
9
10
|
import { safeRedirect } from "../lib/safe-redirect";
|
|
10
11
|
import { signIn } from "../lib/session.server";
|
|
11
12
|
|
|
@@ -82,7 +83,7 @@ function signupErrorMessage(error: unknown): string {
|
|
|
82
83
|
export const Route = createFileRoute("/signup")({
|
|
83
84
|
validateSearch: signupSearch,
|
|
84
85
|
loader: () => getSignupAvailable(),
|
|
85
|
-
head: () => ({ meta: [{ title: "회원가입" }] }),
|
|
86
|
+
head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "회원가입") }] }),
|
|
86
87
|
component: Signup,
|
|
87
88
|
});
|
|
88
89
|
|