@sayren/mcp 0.1.1 → 0.1.2
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 +1 -1
- package/template/README.md +8 -0
- package/template/app/lib/analytics.ts +60 -0
- package/template/app/lib/api.server.ts +4 -1
- package/template/app/lib/config.server.ts +3 -0
- package/template/app/root.tsx +18 -1
- package/template/app/routes/home.tsx +10 -2
- package/template/app/routes/product-detail.tsx +2 -0
- package/template/app/routes/products.tsx +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sayren/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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/
|
|
16
|
-
"@sayren/
|
|
15
|
+
"@sayren/storefront-sdk": "^0.2.0",
|
|
16
|
+
"@sayren/store-sdk": "^0.1.1"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@biomejs/biome": "^2.5.14",
|
package/template/README.md
CHANGED
|
@@ -16,10 +16,18 @@ pnpm dev
|
|
|
16
16
|
| --- | --- |
|
|
17
17
|
| `SAYREN_API_URL` | 스토어프론트 API 베이스 |
|
|
18
18
|
| `SAYREN_STORE_CODE` | 테넌트 스토어 코드. 서브도메인으로 서비스하면 비워 둔다 |
|
|
19
|
+
| `SAYREN_ANALYTICS_DEBUG` | `1`이면 localhost에서도 방문 분석을 보낸다 |
|
|
19
20
|
|
|
20
21
|
`pnpm dev`는 프로젝트 루트의 `.env`에서 `SAYREN_*`를 읽는다. 셸에서 준 값이 우선한다.
|
|
21
22
|
`pnpm start`(빌드 결과 실행)는 `.env`를 읽지 않으므로 환경변수로 넘긴다.
|
|
22
23
|
|
|
24
|
+
## 방문 분석
|
|
25
|
+
|
|
26
|
+
`app/lib/analytics.ts`가 브라우저에서 방문 분석을 시작한다. 페이지뷰·체류는 자동으로 세고, 상품 조회·목록 노출·검색은
|
|
27
|
+
화면에서 `useTrack`으로 남긴다. 장바구니·결제·구매는 `app/lib/api.server.ts`가 방문자 쿠키를 API 요청 헤더에 실어
|
|
28
|
+
서버가 기록한다. localhost에서는 보내지 않는다. 개발 중에 확인하려면 `SAYREN_ANALYTICS_DEBUG=1`을 준다.
|
|
29
|
+
쿠키 동의 배너를 붙인다면 `consent`를 `"pending"`으로 바꾸고 배너에서 `setConsent`를 부른다.
|
|
30
|
+
|
|
23
31
|
## 확장 지점
|
|
24
32
|
|
|
25
33
|
| 자리 | 파일 |
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Analytics,
|
|
3
|
+
type AnalyticsOptions,
|
|
4
|
+
type AnalyticsTrackInput,
|
|
5
|
+
createAnalytics,
|
|
6
|
+
} from "@sayren/storefront-sdk/analytics";
|
|
7
|
+
import { useEffect } from "react";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 방문 분석 — 브라우저 전용. 루트가 한 번 시작하고, 화면은 `useTrack`으로 행동을 남긴다.
|
|
11
|
+
*
|
|
12
|
+
* 장바구니·결제 시작·구매는 여기서 보내지 않는다. 스토어프론트 API가 요청을 처리하면서 서버에서
|
|
13
|
+
* 기록한다(`api.server.ts`가 방문자·세션 쿠키를 헤더로 싣는다).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 루트 loader가 내려주는 값 */
|
|
17
|
+
export interface AnalyticsConfig {
|
|
18
|
+
apiBaseUrl: string;
|
|
19
|
+
storeCode: string;
|
|
20
|
+
/** localhost에서도 보낸다(`SAYREN_ANALYTICS_DEBUG=1`) */
|
|
21
|
+
debug: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let instance: Analytics | null = null;
|
|
25
|
+
/**
|
|
26
|
+
* 시작 전에 들어온 행동. React는 자식 effect를 부모보다 먼저 돌리므로 첫 화면의 `useTrack`이
|
|
27
|
+
* 루트의 시작보다 앞선다.
|
|
28
|
+
*/
|
|
29
|
+
let pending: AnalyticsTrackInput[] = [];
|
|
30
|
+
|
|
31
|
+
export function startAnalytics(config: AnalyticsConfig): Analytics {
|
|
32
|
+
if (instance) return instance;
|
|
33
|
+
const options: AnalyticsOptions = {
|
|
34
|
+
baseUrl: config.apiBaseUrl,
|
|
35
|
+
storeCode: config.storeCode,
|
|
36
|
+
debug: config.debug,
|
|
37
|
+
// 쿠키 동의 배너를 붙이는 스토어는 "pending"으로 시작하고 배너에서 setConsent를 부른다
|
|
38
|
+
consent: "granted",
|
|
39
|
+
};
|
|
40
|
+
instance = createAnalytics(options);
|
|
41
|
+
for (const input of pending) instance.track(input);
|
|
42
|
+
pending = [];
|
|
43
|
+
return instance;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function track(input: AnalyticsTrackInput) {
|
|
47
|
+
if (instance) instance.track(input);
|
|
48
|
+
else pending.push(input);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 화면이 보일 때 행동을 한 번 남긴다. 내용이 같으면 리렌더·StrictMode 이중 실행에도 다시 보내지
|
|
53
|
+
* 않는다(SDK도 같은 페이지뷰 안의 중복을 거른다). `null`이면 아무것도 보내지 않는다.
|
|
54
|
+
*/
|
|
55
|
+
export function useTrack(input: AnalyticsTrackInput | null) {
|
|
56
|
+
const key = input ? JSON.stringify(input) : "";
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (key) track(JSON.parse(key) as AnalyticsTrackInput);
|
|
59
|
+
}, [key]);
|
|
60
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createStorefrontClient } from "@sayren/storefront-sdk";
|
|
1
|
+
import { analyticsIdsFromCookie, createStorefrontClient } from "@sayren/storefront-sdk";
|
|
2
2
|
import { API_BASE_URL, resolveStoreCode } from "./config.server";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -7,6 +7,8 @@ import { API_BASE_URL, resolveStoreCode } from "./config.server";
|
|
|
7
7
|
* 테넌트는 `X-Store-Code` 헤더로 보낸다. 토큰은 요청마다 넘긴다 — 모듈 전역에 담으면 서버 렌더에서
|
|
8
8
|
* 다른 사용자의 요청에 섞인다.
|
|
9
9
|
*
|
|
10
|
+
* 방문 분석 쿠키(방문자·세션)를 헤더로 실어 서버가 기록하는 장바구니·결제·구매 이벤트를 방문과 잇는다.
|
|
11
|
+
*
|
|
10
12
|
* 비회원 장바구니 토큰은 서버가 새로 발급할 수 있다. `onCartToken`으로 받아 쿠키에 다시 심는다.
|
|
11
13
|
*/
|
|
12
14
|
export function apiFor(
|
|
@@ -23,6 +25,7 @@ export function apiFor(
|
|
|
23
25
|
accessToken: options.accessToken ?? undefined,
|
|
24
26
|
cartToken: options.cartToken ?? undefined,
|
|
25
27
|
onCartToken: options.onCartToken,
|
|
28
|
+
...analyticsIdsFromCookie(request.headers.get("cookie")),
|
|
26
29
|
// 결제 요청에 우리 화면의 origin을 실어 보낸다. 결제 팝업은 이 값으로만 부모 창에
|
|
27
30
|
// 결과를 알린다 — 빠지면 결제는 되지만 주문서가 완료를 못 받는다.
|
|
28
31
|
fetch: (input, init) => {
|
|
@@ -6,6 +6,9 @@ import { storeCodeFromHost } from "./config";
|
|
|
6
6
|
*/
|
|
7
7
|
export const API_BASE_URL = process.env.SAYREN_API_URL ?? "https://api.sayren.app/storefront/v1";
|
|
8
8
|
|
|
9
|
+
/** localhost에서도 방문 분석을 보낸다 — 기본은 개발 트래픽을 섞지 않으려고 끈다 */
|
|
10
|
+
export const ANALYTICS_DEBUG = process.env.SAYREN_ANALYTICS_DEBUG === "1";
|
|
11
|
+
|
|
9
12
|
const FIXED_STORE_CODE = process.env.SAYREN_STORE_CODE ?? "";
|
|
10
13
|
|
|
11
14
|
/** 요청에서 테넌트를 정한다 — 고정 값이 먼저이고, 없으면 호스트 서브도메인에서 뽑는다 */
|
package/template/app/root.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
1
2
|
import {
|
|
2
3
|
isRouteErrorResponse,
|
|
3
4
|
Links,
|
|
@@ -8,6 +9,8 @@ import {
|
|
|
8
9
|
} from "react-router";
|
|
9
10
|
import type { Route } from "./+types/root";
|
|
10
11
|
import { SiteHeader } from "./components/site-header";
|
|
12
|
+
import { type AnalyticsConfig, startAnalytics } from "./lib/analytics";
|
|
13
|
+
import { ANALYTICS_DEBUG, API_BASE_URL, resolveStoreCode } from "./lib/config.server";
|
|
11
14
|
import "./app.css";
|
|
12
15
|
|
|
13
16
|
export function Layout({ children }: { children: React.ReactNode }) {
|
|
@@ -29,7 +32,21 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|
|
29
32
|
);
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
export
|
|
35
|
+
export function loader({ request }: Route.LoaderArgs) {
|
|
36
|
+
const analytics: AnalyticsConfig = {
|
|
37
|
+
apiBaseUrl: API_BASE_URL,
|
|
38
|
+
storeCode: resolveStoreCode(request),
|
|
39
|
+
debug: ANALYTICS_DEBUG,
|
|
40
|
+
};
|
|
41
|
+
return { analytics };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default function App({ loaderData }: Route.ComponentProps) {
|
|
45
|
+
const { analytics } = loaderData;
|
|
46
|
+
// 방문 분석은 브라우저에서 한 번 시작한다. 이후 페이지뷰는 SDK가 History API 이동으로 센다
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
startAnalytics(analytics);
|
|
49
|
+
}, [analytics]);
|
|
33
50
|
return <Outlet />;
|
|
34
51
|
}
|
|
35
52
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Link } from "react-router";
|
|
2
2
|
import { ProductCard } from "../components/product-card";
|
|
3
|
+
import { useTrack } from "../lib/analytics";
|
|
3
4
|
import { apiFor } from "../lib/api.server";
|
|
4
5
|
import type { Route } from "./+types/home";
|
|
5
6
|
|
|
@@ -40,19 +41,26 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|
|
40
41
|
</nav>
|
|
41
42
|
) : null}
|
|
42
43
|
|
|
43
|
-
<ProductSection title="추천 상품" products={recommended} />
|
|
44
|
-
<ProductSection title="새로 들어왔어요" products={latest} />
|
|
44
|
+
<ProductSection listId="home:recommend" title="추천 상품" products={recommended} />
|
|
45
|
+
<ProductSection listId="home:latest" title="새로 들어왔어요" products={latest} />
|
|
45
46
|
</div>
|
|
46
47
|
);
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
function ProductSection({
|
|
51
|
+
listId,
|
|
50
52
|
title,
|
|
51
53
|
products,
|
|
52
54
|
}: {
|
|
55
|
+
listId: string;
|
|
53
56
|
title: string;
|
|
54
57
|
products: Awaited<ReturnType<typeof loader>>["recommended"];
|
|
55
58
|
}) {
|
|
59
|
+
useTrack(
|
|
60
|
+
products.length
|
|
61
|
+
? { name: "product_list_view", listId, productIds: products.map((p) => p.productId) }
|
|
62
|
+
: null,
|
|
63
|
+
);
|
|
56
64
|
if (!products.length) return null;
|
|
57
65
|
return (
|
|
58
66
|
<section className="space-y-4">
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { Form, redirect, useNavigation } from "react-router";
|
|
3
3
|
import { ProductDescription } from "../components/product-description";
|
|
4
|
+
import { useTrack } from "../lib/analytics";
|
|
4
5
|
import { apiFor } from "../lib/api.server";
|
|
5
6
|
import { cartCookie, readCartToken } from "../lib/cart-session.server";
|
|
6
7
|
import { formatPrice } from "../lib/format";
|
|
@@ -46,6 +47,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|
|
46
47
|
|
|
47
48
|
export default function ProductDetail({ loaderData }: Route.ComponentProps) {
|
|
48
49
|
const { product } = loaderData;
|
|
50
|
+
useTrack({ name: "product_view", productId: product.productId });
|
|
49
51
|
const navigation = useNavigation();
|
|
50
52
|
const submitting = navigation.state !== "idle";
|
|
51
53
|
const hasOptions = product.optionGroups.length > 0;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Form, Link, useSearchParams } from "react-router";
|
|
2
2
|
import { ProductCard } from "../components/product-card";
|
|
3
|
+
import { useTrack } from "../lib/analytics";
|
|
3
4
|
import { apiFor } from "../lib/api.server";
|
|
4
5
|
import type { Route } from "./+types/products";
|
|
5
6
|
|
|
@@ -28,6 +29,20 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|
|
28
29
|
export default function Products({ loaderData }: Route.ComponentProps) {
|
|
29
30
|
const { categories, page, applied } = loaderData;
|
|
30
31
|
const [searchParams] = useSearchParams();
|
|
32
|
+
useTrack(
|
|
33
|
+
applied.keyword
|
|
34
|
+
? { name: "search", query: applied.keyword, resultCount: page.totalElements }
|
|
35
|
+
: null,
|
|
36
|
+
);
|
|
37
|
+
useTrack({
|
|
38
|
+
name: "product_list_view",
|
|
39
|
+
listId: applied.keyword
|
|
40
|
+
? "search"
|
|
41
|
+
: applied.categoryId
|
|
42
|
+
? `category:${applied.categoryId}`
|
|
43
|
+
: "all",
|
|
44
|
+
productIds: page.contents.map((product) => product.productId),
|
|
45
|
+
});
|
|
31
46
|
|
|
32
47
|
return (
|
|
33
48
|
<div className="space-y-6">
|