@sayren/mcp 0.1.1 → 0.1.3

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 CHANGED
@@ -529,6 +529,16 @@ const RULES = [
529
529
  title: "팝업 메시지는 origin과 source를 모두 검증한다",
530
530
  why: "검증 없이 받으면 아무 창이나 결제 완료를 흉내 낼 수 있다"
531
531
  },
532
+ {
533
+ id: "analytics-start",
534
+ title: "방문 분석은 브라우저에서 한 번 시작한다(`@sayren/storefront-sdk/analytics`)",
535
+ why: "빠지면 셀러 콘솔 애널리틱스가 비어 있다. 서버 파일에서 부르면 아무것도 하지 않는다"
536
+ },
537
+ {
538
+ id: "analytics-ids",
539
+ title: "서버의 API 클라이언트에 방문 식별 쿠키를 싣는다(`analyticsIdsFromCookie`)",
540
+ why: "빠지면 장바구니·결제 시작·구매가 방문과 이어지지 않아 퍼널과 유입별 매출에서 빠진다"
541
+ },
532
542
  {
533
543
  id: "closed-not-failed",
534
544
  title: "팝업이 결과 없이 닫히면 실패로 단정하지 않는다",
@@ -543,7 +553,7 @@ const CLIENT_FILE = /\.(ts|tsx)$/;
543
553
  const SERVER_FILE = /\.server\.(ts|tsx)$/;
544
554
  /**
545
555
  * 만들어진 프로젝트의 소스를 규칙과 대조한다. 정적 검사라 모든 문제를 잡지는 못하지만,
546
- * 이 아홉 가지는 실제로 반복해서 깨지는 것들이다.
556
+ * 여기 있는 것들은 실제로 반복해서 깨지는 것들이다.
547
557
  */
548
558
  function verifySources(files) {
549
559
  const findings = [];
@@ -554,7 +564,8 @@ function verifySources(files) {
554
564
  const has = (needle) => cleaned.some((file) => file.content.includes(needle));
555
565
  for (const file of cleaned) {
556
566
  if (!CLIENT_FILE.test(file.path)) continue;
557
- if (!(SERVER_FILE.test(file.path) || /\/(server|\.server)\//.test(file.path)) && /process\.env\.[A-Z_]/.test(file.content)) findings.push({
567
+ const isServer = SERVER_FILE.test(file.path) || /\/(server|\.server)\//.test(file.path);
568
+ if (!isServer && /process\.env\.[A-Z_]/.test(file.content)) findings.push({
558
569
  ruleId: "server-only-env",
559
570
  file: file.path,
560
571
  detail: "브라우저로 갈 수 있는 파일에서 process.env를 읽는다. `*.server.ts`로 옮긴다"
@@ -586,6 +597,16 @@ function verifySources(files) {
586
597
  detail: "message 리스너가 source(우리가 연 창)를 검증하지 않는다"
587
598
  });
588
599
  }
600
+ if (isServer && /createAnalytics\(/.test(file.content)) findings.push({
601
+ ruleId: "analytics-start",
602
+ file: file.path,
603
+ detail: "서버 파일에서 방문 분석을 시작한다. 서버에서는 아무것도 하지 않으니 브라우저(effect)에서 부른다"
604
+ });
605
+ if (isServer && /createStorefrontClient\(/.test(file.content) && !/analyticsIdsFromCookie|visitorId/.test(file.content)) findings.push({
606
+ ruleId: "analytics-ids",
607
+ file: file.path,
608
+ detail: "서버 API 클라이언트에 방문 식별자가 없다. `...analyticsIdsFromCookie(request.headers.get(\"cookie\"))`를 넘긴다"
609
+ });
589
610
  if (/requestPayment\(/.test(file.content) && /<form\s/.test(file.content)) findings.push({
590
611
  ruleId: "popup-client-submit",
591
612
  file: file.path,
@@ -602,6 +623,11 @@ function verifySources(files) {
602
623
  file: "(프로젝트 전체)",
603
624
  detail: "팝업이 결과 없이 닫힌 경우 결제 상태를 조회하는 코드가 없다"
604
625
  });
626
+ if (has("createStorefrontClient(") && !has("@sayren/storefront-sdk/analytics")) findings.push({
627
+ ruleId: "analytics-start",
628
+ file: "(프로젝트 전체)",
629
+ detail: "방문 분석을 시작하는 코드가 없다. `@sayren/storefront-sdk/analytics`의 createAnalytics를 브라우저에서 한 번 부른다"
630
+ });
605
631
  if (has("createStorefrontClient(") && !has("loader")) findings.push({
606
632
  ruleId: "loader-first",
607
633
  file: "(프로젝트 전체)",
@@ -674,7 +700,8 @@ server.registerTool("get_scaffold_plan", {
674
700
  ],
675
701
  doNotTouch: [
676
702
  "app/lib/payment-popup.ts — 결제 팝업 규칙",
677
- "app/lib/api.server.ts — 테넌트 헤더·토큰 전달·부모 origin",
703
+ "app/lib/api.server.ts — 테넌트 헤더·토큰 전달·부모 origin·방문 식별 쿠키",
704
+ "app/lib/analytics.ts — 방문 분석 시작(브라우저 한 번)과 시작 전 이벤트 큐",
678
705
  "loader에서 데이터를 받는 구조"
679
706
  ],
680
707
  rules: RULES,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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.1.1",
16
- "@sayren/storefront-sdk": "^0.1.0"
15
+ "@sayren/store-sdk": "^0.3.0",
16
+ "@sayren/storefront-sdk": "^0.2.1"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@biomejs/biome": "^2.5.14",
@@ -7,8 +7,8 @@
7
7
  "zod": "^4.6.5"
8
8
  },
9
9
  "versions": {
10
- "@sayren/store-sdk": "0.1.1",
11
- "@sayren/storefront-sdk": "0.1.0",
10
+ "@sayren/store-sdk": "0.3.0",
11
+ "@sayren/storefront-sdk": "0.2.1",
12
12
  "@sayren/ui": "0.1.0"
13
13
  }
14
14
  }
@@ -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
  /** 요청에서 테넌트를 정한다 — 고정 값이 먼저이고, 없으면 호스트 서브도메인에서 뽑는다 */
@@ -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 default function App() {
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">